Merge dev-2026-03-22 into main #10

Merged
hzhang merged 7 commits from dev-2026-03-22 into main 2026-03-22 14:06:54 +00:00
15 changed files with 803 additions and 48 deletions

View File

@@ -17,11 +17,17 @@ import MonitorPage from '@/pages/MonitorPage'
import ProposesPage from '@/pages/ProposesPage'
import ProposeDetailPage from '@/pages/ProposeDetailPage'
import UsersPage from '@/pages/UsersPage'
import SupportDetailPage from '@/pages/SupportDetailPage'
import MeetingDetailPage from '@/pages/MeetingDetailPage'
import axios from 'axios'
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
const WIZARD_BASE = `http://127.0.0.1:${WIZARD_PORT}`
const getApiBase = () => {
return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000'
}
type AppState = 'checking' | 'setup' | 'ready'
export default function App() {
@@ -33,6 +39,22 @@ export default function App() {
}, [])
const checkInitialized = async () => {
// First try the backend /config/status endpoint (reads from config volume directly)
try {
const res = await axios.get(`${getApiBase()}/config/status`, { timeout: 5000 })
const cfg = res.data || {}
if (cfg.backend_url) {
localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url)
}
if (cfg.initialized === true) {
setAppState('ready')
return
}
} catch {
// Backend unreachable — fall through to wizard check
}
// Fallback: try the wizard directly (needed during initial setup before backend starts)
try {
const res = await axios.get(`${WIZARD_BASE}/api/v1/config/harborforge.json`, {
timeout: 5000,
@@ -47,7 +69,7 @@ export default function App() {
setAppState('setup')
}
} catch {
// Wizard unreachable or config doesn't exist → setup needed
// Neither backend nor wizard reachable → setup needed
setAppState('setup')
}
}
@@ -96,6 +118,8 @@ export default function App() {
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
<Route path="/proposes" element={<ProposesPage />} />
<Route path="/proposes/:id" element={<ProposeDetailPage />} />
<Route path="/meetings/:meetingId" element={<MeetingDetailPage />} />
<Route path="/supports/:supportId" element={<SupportDetailPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/roles" element={<RoleEditorPage />} />
<Route path="/users" element={<UsersPage />} />

View File

@@ -0,0 +1,45 @@
import { useState } from 'react'
interface Props {
code: string
prefix?: string
}
export default function CopyableCode({ code, prefix }: Props) {
const [copied, setCopied] = useState(false)
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// fallback: select text
}
}
return (
<span
className="copyable-code"
title="Click to copy code"
onClick={handleCopy}
style={{
cursor: 'pointer',
fontFamily: 'monospace',
padding: '2px 8px',
borderRadius: '4px',
background: 'var(--bg-hover, rgba(255,255,255,.06))',
border: '1px solid var(--border, rgba(255,255,255,.1))',
fontSize: '0.95em',
userSelect: 'all',
transition: 'background .15s',
}}
>
{prefix}{code}
{copied && (
<span style={{ marginLeft: 6, fontSize: '0.8em', color: 'var(--success, #10b981)' }}></span>
)}
</span>
)
}

View File

@@ -55,13 +55,13 @@ export default function DashboardPage() {
<h3>Recent Tasks</h3>
<table>
<thead>
<tr><th>ID</th><th>Title</th><th>Status</th><th>Priority</th><th>Type</th><th>Subtype</th></tr>
<tr><th>Code</th><th>Title</th><th>Status</th><th>Priority</th><th>Type</th><th>Subtype</th></tr>
</thead>
<tbody>
{(stats.recent_tasks || []).map((i) => (
<tr key={i.id}>
<td>#{i.id}</td>
<td><a href={`/tasks/${i.id}`}>{i.title}</a></td>
<td>{i.task_code || `#${i.id}`}</td>
<td><a href={`/tasks/${i.task_code || i.id}`}>{i.title}</a></td>
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
<td>{i.task_type}</td><td>{i.task_subtype || "-"}</td>

View File

@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '@/services/api'
import { useAuth } from '@/hooks/useAuth'
import dayjs from 'dayjs'
import CopyableCode from '@/components/CopyableCode'
interface MeetingItem {
id: number
code: string | null
meeting_code: string | null
title: string
description: string | null
status: string
priority: string
project_id: number
project_code: string | null
milestone_id: number
milestone_code: string | null
reporter_id: number
meeting_time: string | null
scheduled_at: string | null
duration_minutes: number | null
participants: string[]
created_at: string
updated_at: string | null
}
const STATUS_OPTIONS = ['scheduled', 'in_progress', 'completed', 'cancelled']
export default function MeetingDetailPage() {
const { meetingId } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const [meeting, setMeeting] = useState<MeetingItem | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [editMode, setEditMode] = useState(false)
const [editForm, setEditForm] = useState({
title: '',
description: '',
meeting_time: '',
duration_minutes: '',
})
const fetchMeeting = async () => {
try {
const { data } = await api.get<MeetingItem>(`/meetings/${meetingId}`)
setMeeting(data)
setEditForm({
title: data.title,
description: data.description || '',
meeting_time: data.meeting_time || data.scheduled_at || '',
duration_minutes: data.duration_minutes ? String(data.duration_minutes) : '',
})
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to load meeting')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchMeeting()
}, [meetingId])
const handleAttend = async () => {
if (!meeting) return
setSaving(true)
setMessage('')
try {
const { data } = await api.post<MeetingItem>(`/meetings/${meetingId}/attend`)
setMeeting(data)
setMessage('You have joined this meeting')
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to attend meeting')
} finally {
setSaving(false)
}
}
const handleTransition = async (newStatus: string) => {
if (!meeting) return
setSaving(true)
setMessage('')
try {
const { data } = await api.patch<MeetingItem>(`/meetings/${meetingId}`, {
status: newStatus,
})
setMeeting(data)
setMessage(`Status changed to ${newStatus}`)
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to update meeting status')
} finally {
setSaving(false)
}
}
const handleSave = async () => {
if (!meeting) return
setSaving(true)
setMessage('')
try {
const payload: Record<string, any> = {}
if (editForm.title.trim() !== meeting.title) payload.title = editForm.title.trim()
if ((editForm.description || '') !== (meeting.description || '')) payload.description = editForm.description || null
const currentTime = meeting.meeting_time || meeting.scheduled_at || ''
if (editForm.meeting_time !== currentTime) payload.meeting_time = editForm.meeting_time || null
const currentDuration = meeting.duration_minutes ? String(meeting.duration_minutes) : ''
if (editForm.duration_minutes !== currentDuration) {
payload.duration_minutes = editForm.duration_minutes ? Number(editForm.duration_minutes) : null
}
if (Object.keys(payload).length === 0) {
setEditMode(false)
return
}
const { data } = await api.patch<MeetingItem>(`/meetings/${meetingId}`, payload)
setMeeting(data)
setEditMode(false)
setMessage('Meeting updated')
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to update meeting')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!meeting) return
if (!confirm(`Delete meeting ${meeting.meeting_code || meeting.id}? This cannot be undone.`)) return
setSaving(true)
try {
await api.delete(`/meetings/${meetingId}`)
navigate(-1)
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to delete meeting')
setSaving(false)
}
}
if (loading) return <div className="loading">Loading meeting...</div>
if (!meeting) return <div className="loading">{message || 'Meeting not found'}</div>
const isParticipant = user && meeting.participants.includes(user.username)
const canAttend = user && !isParticipant && meeting.status !== 'completed' && meeting.status !== 'cancelled'
const availableTransitions = STATUS_OPTIONS.filter((s) => s !== meeting.status)
const scheduledTime = meeting.meeting_time || meeting.scheduled_at
return (
<div className="section">
<button className="btn-back" onClick={() => navigate(-1)}> Back</button>
<div className="task-header">
<h2>📅 {meeting.meeting_code ? <CopyableCode code={meeting.meeting_code} /> : `#${meeting.id}`}</h2>
<div className="task-meta">
<span className={`badge status-${meeting.status}`}>{meeting.status}</span>
{meeting.project_code && <span className="text-dim">Project: {meeting.project_code}</span>}
{meeting.milestone_code && <span className="text-dim">Milestone: {meeting.milestone_code}</span>}
</div>
</div>
{message && (
<div
style={{
padding: '10px 12px',
marginBottom: '16px',
borderRadius: '8px',
background: message.toLowerCase().includes('fail') || message.toLowerCase().includes('error')
? 'rgba(239,68,68,.12)'
: 'rgba(16,185,129,.12)',
border: `1px solid ${
message.toLowerCase().includes('fail') || message.toLowerCase().includes('error')
? 'rgba(239,68,68,.35)'
: 'rgba(16,185,129,.35)'
}`,
}}
>
{message}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '20px', alignItems: 'start' }}>
{/* Main content */}
<div className="monitor-card">
{editMode ? (
<div className="task-create-form">
<label>
Title
<input
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
/>
</label>
<label>
Description
<textarea
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={6}
style={{ width: '100%' }}
/>
</label>
<label>
Scheduled Time
<input
type="datetime-local"
value={editForm.meeting_time ? dayjs(editForm.meeting_time).format('YYYY-MM-DDTHH:mm') : ''}
onChange={(e) => setEditForm({ ...editForm, meeting_time: e.target.value ? new Date(e.target.value).toISOString() : '' })}
/>
</label>
<label>
Duration (minutes)
<input
type="number"
value={editForm.duration_minutes}
onChange={(e) => setEditForm({ ...editForm, duration_minutes: e.target.value })}
min={0}
/>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn-primary" disabled={saving} onClick={handleSave}>
{saving ? 'Saving...' : 'Save'}
</button>
<button className="btn-back" onClick={() => setEditMode(false)}>Cancel</button>
</div>
</div>
) : (
<>
<h3>{meeting.title}</h3>
{scheduledTime && (
<div style={{ marginTop: 8 }}>
<strong>Scheduled:</strong> {dayjs(scheduledTime).format('YYYY-MM-DD HH:mm')}
{meeting.duration_minutes && <span className="text-dim" style={{ marginLeft: 8 }}>({meeting.duration_minutes} min)</span>}
</div>
)}
{!scheduledTime && <div className="text-dim" style={{ marginTop: 8 }}>No scheduled time set.</div>}
{meeting.description && (
<p style={{ whiteSpace: 'pre-wrap', marginTop: 12 }}>{meeting.description}</p>
)}
{!meeting.description && <p className="text-dim" style={{ marginTop: 12 }}>No description provided.</p>}
</>
)}
</div>
{/* Sidebar */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Participants */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Participants ({meeting.participants.length})</h4>
{meeting.participants.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{meeting.participants.map((p) => (
<span key={p} className="badge">
{p}
{user && p === user.username && ' (you)'}
</span>
))}
</div>
) : (
<span className="text-dim">No participants yet</span>
)}
{canAttend && (
<button
className="btn-primary"
style={{ marginTop: 8, width: '100%' }}
disabled={saving}
onClick={handleAttend}
>
Attend
</button>
)}
</div>
{/* Status transitions */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Status</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{availableTransitions.map((s) => (
<button
key={s}
className="btn-secondary"
disabled={saving}
onClick={() => handleTransition(s)}
style={{ textAlign: 'left' }}
>
{s}
</button>
))}
</div>
</div>
{/* Actions */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Actions</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!editMode && (
<button className="btn-secondary" onClick={() => setEditMode(true)}>
Edit
</button>
)}
<button className="btn-danger" disabled={saving} onClick={handleDelete}>
Delete
</button>
</div>
</div>
{/* Info */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Info</h4>
<div className="text-dim" style={{ fontSize: '0.9em' }}>
<div>Created: {new Date(meeting.created_at).toLocaleString()}</div>
{meeting.updated_at && <div>Updated: {new Date(meeting.updated_at).toLocaleString()}</div>}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import dayjs from 'dayjs'
import CreateTaskModal from '@/components/CreateTaskModal'
import MilestoneFormModal from '@/components/MilestoneFormModal'
import { useAuth } from '@/hooks/useAuth'
import CopyableCode from '@/components/CopyableCode'
interface MilestoneTask {
id: number
@@ -31,8 +32,8 @@ export default function MilestoneDetailPage() {
const [members, setMembers] = useState<ProjectMember[]>([])
const [progress, setProgress] = useState<MilestoneProgress | null>(null)
const [tasks, setTasks] = useState<MilestoneTask[]>([])
const [supports, setSupports] = useState<Task[]>([])
const [meetings, setMeetings] = useState<Task[]>([])
const [supports, setSupports] = useState<any[]>([])
const [meetings, setMeetings] = useState<any[]>([])
const [activeTab, setActiveTab] = useState<'tasks' | 'supports' | 'meetings'>('tasks')
const [showCreateTask, setShowCreateTask] = useState(false)
const [showEditMilestone, setShowEditMilestone] = useState(false)
@@ -56,14 +57,14 @@ export default function MilestoneDetailPage() {
setProjectCode(proj.project_code || '')
})
api.get<ProjectMember[]>(`/projects/${data.project_id}/members`).then(({ data }) => setMembers(data)).catch(() => {})
fetchPreflight(data.project_id)
fetchPreflight(data.project_id, data.id)
}
api.get<MilestoneProgress>(`/milestones/${data.id}/progress`).then(({ data: prog }) => setProgress(prog)).catch(() => {})
})
api.get<MilestoneProgress>(`/milestones/${id}/progress`).then(({ data }) => setProgress(data)).catch(() => {})
}
const fetchPreflight = (projectId: number) => {
api.get(`/projects/${projectId}/milestones/${id}/actions/preflight`)
const fetchPreflight = (projectId: number, milestoneId: number) => {
api.get(`/projects/${projectId}/milestones/${milestoneId}/actions/preflight`)
.then(({ data }) => setPreflight(data))
.catch(() => setPreflight(null))
}
@@ -73,23 +74,23 @@ export default function MilestoneDetailPage() {
}, [id])
const refreshMilestoneItems = () => {
if (!projectCode || !id) return
api.get<MilestoneTask[]>(`/tasks/${projectCode}/${id}`).then(({ data }) => setTasks(data)).catch(() => {})
api.get<Task[]>(`/supports/${projectCode}/${id}`).then(({ data }) => setSupports(data)).catch(() => {})
api.get<Task[]>(`/meetings/${projectCode}/${id}`).then(({ data }) => setMeetings(data)).catch(() => {})
if (!projectCode || !milestone) return
api.get<MilestoneTask[]>(`/tasks/${projectCode}/${milestone.id}`).then(({ data }) => setTasks(data)).catch(() => {})
api.get<any[]>(`/supports/${projectCode}/${milestone.id}`).then(({ data }) => setSupports(data)).catch(() => {})
api.get<any[]>(`/meetings/${projectCode}/${milestone.id}`).then(({ data }) => setMeetings(data)).catch(() => {})
}
useEffect(() => {
refreshMilestoneItems()
}, [projectCode, id])
}, [projectCode, milestone?.id])
const createItem = async (type: 'supports' | 'meetings') => {
if (!newTitle.trim() || !projectCode) return
if (!newTitle.trim() || !projectCode || !milestone) return
const payload = {
title: newTitle,
description: newDesc || null,
}
await api.post(`/${type}/${projectCode}/${id}`, payload)
await api.post(`/${type}/${projectCode}/${milestone.id}`, payload)
setNewTitle('')
setNewDesc('')
setShowCreateSupport(false)
@@ -121,7 +122,7 @@ export default function MilestoneDetailPage() {
await api.post(`/projects/${project.id}/milestones/${milestone.id}/actions/${action}`, body ?? {})
fetchMilestone()
refreshMilestoneItems()
fetchPreflight(project.id)
fetchPreflight(project.id, milestone.id)
} catch (err: any) {
const detail = err?.response?.data?.detail
setActionError(typeof detail === 'string' ? detail : `${action} failed`)
@@ -141,7 +142,7 @@ export default function MilestoneDetailPage() {
if (!milestone) return <div className="loading">Loading...</div>
const renderTaskRow = (t: MilestoneTask) => (
<tr key={t.id} className="clickable" onClick={() => navigate(`/tasks/${t.id}`)}>
<tr key={t.id} className="clickable" onClick={() => navigate(`/tasks/${t.task_code || t.id}`)}>
<td>{t.task_code || t.id}</td>
<td className="task-title">{t.title}</td>
<td><span className={`badge status-${t.task_status || t.status}`}>{t.task_status || t.status}</span></td>
@@ -155,7 +156,7 @@ export default function MilestoneDetailPage() {
<button className="btn-back" onClick={() => navigate('/milestones')}> Back to Milestones</button>
<div className="task-header">
<h2>🏁 {milestone.title}</h2>
<h2>🏁 {milestone.milestone_code && <><CopyableCode code={milestone.milestone_code} /> </>}{milestone.title}</h2>
<div className="task-meta">
<span className={`badge status-${milestone.status}`}>{milestone.status}</span>
{milestone.due_date && <span className="text-dim">Due {dayjs(milestone.due_date).format('YYYY-MM-DD')}</span>}
@@ -352,11 +353,11 @@ export default function MilestoneDetailPage() {
{activeTab === 'supports' && (
<table>
<thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
<thead><tr><th>Code</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
<tbody>
{supports.map((i) => (
<tr key={i.id} className="clickable" onClick={() => navigate(`/supports/${projectCode}/${id}/${i.id}`)}>
<td>{i.id}</td>
<tr key={i.id} className="clickable" onClick={() => navigate(`/supports/${i.support_code || i.id}`)}>
<td>{i.support_code || i.id}</td>
<td className="task-title">{i.title}</td>
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
@@ -369,11 +370,11 @@ export default function MilestoneDetailPage() {
{activeTab === 'meetings' && (
<table>
<thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
<thead><tr><th>Code</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
<tbody>
{meetings.map((i) => (
<tr key={i.id} className="clickable" onClick={() => navigate(`/meetings/${projectCode}/${id}/${i.id}`)}>
<td>{i.id}</td>
<tr key={i.id} className="clickable" onClick={() => navigate(`/meetings/${i.meeting_code || i.id}`)}>
<td>{i.meeting_code || i.id}</td>
<td className="task-title">{i.title}</td>
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>

View File

@@ -49,10 +49,10 @@ export default function MilestonesPage() {
<div className="milestone-grid">
{milestones.map((ms) => (
<div key={ms.id} className="milestone-card" onClick={() => navigate(`/milestones/${ms.id}`)}>
<div key={ms.id} className="milestone-card" onClick={() => navigate(`/milestones/${ms.milestone_code || ms.id}`)}>
<div className="milestone-card-header">
<span className={`badge status-${ms.status}`}>{ms.status}</span>
<h3>{ms.title}</h3>
<h3>{ms.title}</h3>{ms.milestone_code && <span className="badge" style={{ marginLeft: 8, fontSize: '0.75em' }}>{ms.milestone_code}</span>}
</div>
<p className="project-desc">{ms.description || 'No description'}</p>
<div className="project-meta">

View File

@@ -6,6 +6,7 @@ import dayjs from 'dayjs'
import { useAuth } from '@/hooks/useAuth'
import ProjectFormModal from '@/components/ProjectFormModal'
import MilestoneFormModal from '@/components/MilestoneFormModal'
import CopyableCode from '@/components/CopyableCode'
export default function ProjectDetailPage() {
const { id } = useParams()
@@ -25,7 +26,7 @@ export default function ProjectDetailPage() {
const fetchProject = () => {
api.get<Project>(`/projects/${id}`).then(({ data }) => setProject(data))
api.get<ProjectMember[]>(`/projects/${id}/members`).then(({ data }) => setMembers(data))
api.get<Milestone[]>(`/milestones?project_id=${id}`).then(({ data }) => setMilestones(data))
api.get<Milestone[]>(`/projects/${id}/milestones`).then(({ data }) => setMilestones(data))
}
useEffect(() => {
@@ -74,7 +75,7 @@ export default function ProjectDetailPage() {
<button className="btn-back" onClick={() => navigate('/projects')}> Back to projects</button>
<div className="task-header">
<h2>📁 {project.name} {project.project_code && <span className="badge">{project.project_code}</span>}</h2>
<h2>📁 {project.name} {project.project_code && <CopyableCode code={project.project_code} />}</h2>
<p style={{ color: 'var(--text-dim)', marginTop: 4 }}>{project.description || 'No description'}</p>
{project.repo && <p style={{ color: 'var(--text-dim)', marginTop: 4 }}>📦 {project.repo}</p>}
<div className="text-dim">Owner: {project.owner_name || 'Unknown'}</div>
@@ -92,7 +93,7 @@ export default function ProjectDetailPage() {
<div className="member-list">
{members.map((m) => (
<span key={m.id} className="badge" style={{ marginRight: 8 }}>
{`User #${m.user_id} (${m.role})`}
{`${m.username || m.full_name || `User #${m.user_id}`} (${m.role})`}
{canEditProject && (
<button
onClick={(e) => { e.stopPropagation(); removeMember(m.user_id, m.role) }}
@@ -113,7 +114,7 @@ export default function ProjectDetailPage() {
{canEditProject && <button className="btn-sm" onClick={() => setShowMilestoneModal(true)}>+ New</button>}
</h3>
{milestones.map((ms) => (
<div key={ms.id} className="milestone-item" onClick={() => navigate(`/milestones/${ms.id}`)}>
<div key={ms.id} className="milestone-item" onClick={() => navigate(`/milestones/${ms.milestone_code || ms.id}`)}>
<span className={`badge status-${ms.status === 'open' ? 'open' : ms.status === 'closed' ? 'closed' : 'in_progress'}`}>{ms.status}</span>
<span className="milestone-title">{ms.title}</span>
{ms.due_date && <span className="text-dim"> · Due {dayjs(ms.due_date).format('YYYY-MM-DD')}</span>}

View File

@@ -31,7 +31,7 @@ export default function ProjectsPage() {
<div className="project-grid">
{projects.map((p) => (
<div key={p.id} className="project-card" onClick={() => navigate(`/projects/${p.id}`)}>
<div key={p.id} className="project-card" onClick={() => navigate(`/projects/${p.project_code || p.id}`)}>
<h3>{p.name}</h3>{p.project_code && <span className="badge" style={{ marginLeft: 8 }}>{p.project_code}</span>}
<p className="project-desc">{p.description || 'No description'}</p>
<div className="project-meta">

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import api from '@/services/api'
import type { Propose, Milestone } from '@/types'
import dayjs from 'dayjs'
import CopyableCode from '@/components/CopyableCode'
export default function ProposeDetailPage() {
const { id } = useParams()
@@ -124,7 +125,7 @@ export default function ProposeDetailPage() {
<div className="task-header">
<h2>
💡 {propose.title}
{propose.propose_code && <span className="badge" style={{ marginLeft: 8 }}>{propose.propose_code}</span>}
{propose.propose_code && <span style={{ marginLeft: 8 }}><CopyableCode code={propose.propose_code} /></span>}
</h2>
<span className={`badge ${statusBadgeClass(propose.status)}`} style={{ fontSize: '1rem' }}>
{propose.status}
@@ -136,9 +137,9 @@ export default function ProposeDetailPage() {
<div className="section">
<h3>Details</h3>
<div className="detail-grid">
<div><strong>Propose Code:</strong> {propose.propose_code || '—'}</div>
<div><strong>Propose Code:</strong> {propose.propose_code ? <CopyableCode code={propose.propose_code} /> : '—'}</div>
<div><strong>Status:</strong> {propose.status}</div>
<div><strong>Created By:</strong> User #{propose.created_by_id || '—'}</div>
<div><strong>Created By:</strong> {propose.created_by_username || (propose.created_by_id ? `User #${propose.created_by_id}` : '—')}</div>
<div><strong>Created:</strong> {dayjs(propose.created_at).format('YYYY-MM-DD HH:mm')}</div>
<div><strong>Updated:</strong> {propose.updated_at ? dayjs(propose.updated_at).format('YYYY-MM-DD HH:mm') : '—'}</div>
<div><strong>Feature Task:</strong> {propose.feat_task_id || '—'}</div>

View File

@@ -72,7 +72,7 @@ export default function ProposesPage() {
<div className="milestone-grid">
{proposes.map((pr) => (
<div key={pr.id} className="milestone-card" onClick={() => navigate(`/proposes/${pr.id}?project_id=${pr.project_id}`)}>
<div key={pr.id} className="milestone-card" onClick={() => navigate(`/proposes/${pr.propose_code || pr.id}?project_id=${pr.project_id}`)}>
<div className="milestone-card-header">
<span className={`badge ${statusBadgeClass(pr.status)}`}>{pr.status}</span>
{pr.propose_code && <span className="badge">{pr.propose_code}</span>}

View File

@@ -0,0 +1,299 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '@/services/api'
import { useAuth } from '@/hooks/useAuth'
import CopyableCode from '@/components/CopyableCode'
interface SupportItem {
id: number
code: string | null
support_code: string | null
title: string
description: string | null
status: string
priority: string
project_id: number
project_code: string | null
milestone_id: number
milestone_code: string | null
reporter_id: number
assignee_id: number | null
taken_by: string | null
created_at: string
updated_at: string | null
}
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
const PRIORITY_OPTIONS = ['low', 'medium', 'high', 'critical']
export default function SupportDetailPage() {
const { supportId } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const [support, setSupport] = useState<SupportItem | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [editMode, setEditMode] = useState(false)
const [editForm, setEditForm] = useState({ title: '', description: '', priority: '' })
const [transitionStatus, setTransitionStatus] = useState('')
const fetchSupport = async () => {
try {
const { data } = await api.get<SupportItem>(`/supports/${supportId}`)
setSupport(data)
setEditForm({
title: data.title,
description: data.description || '',
priority: data.priority,
})
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to load support ticket')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSupport()
}, [supportId])
const handleTake = async () => {
if (!support) return
setSaving(true)
setMessage('')
try {
const { data } = await api.post<SupportItem>(`/supports/${supportId}/take`)
setSupport(data)
setMessage('You have taken this support ticket')
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to take support ticket')
} finally {
setSaving(false)
}
}
const handleTransition = async () => {
if (!support || !transitionStatus) return
setSaving(true)
setMessage('')
try {
const { data } = await api.post<SupportItem>(`/supports/${supportId}/transition`, {
status: transitionStatus,
})
setSupport(data)
setTransitionStatus('')
setMessage(`Status changed to ${transitionStatus}`)
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to transition support ticket')
} finally {
setSaving(false)
}
}
const handleSave = async () => {
if (!support) return
setSaving(true)
setMessage('')
try {
const payload: Record<string, any> = {}
if (editForm.title.trim() !== support.title) payload.title = editForm.title.trim()
if ((editForm.description || '') !== (support.description || '')) payload.description = editForm.description || null
if (editForm.priority !== support.priority) payload.priority = editForm.priority
if (Object.keys(payload).length === 0) {
setEditMode(false)
return
}
const { data } = await api.patch<SupportItem>(`/supports/${supportId}`, payload)
setSupport(data)
setEditMode(false)
setMessage('Support ticket updated')
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to update support ticket')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!support) return
if (!confirm(`Delete support ticket ${support.support_code || support.id}? This cannot be undone.`)) return
setSaving(true)
try {
await api.delete(`/supports/${supportId}`)
navigate(-1)
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to delete support ticket')
setSaving(false)
}
}
if (loading) return <div className="loading">Loading support ticket...</div>
if (!support) return <div className="loading">{message || 'Support ticket not found'}</div>
const canTake = user && (!support.assignee_id || support.assignee_id === user.id) && support.status !== 'closed' && support.status !== 'resolved'
const isMine = user && support.assignee_id === user.id
const availableTransitions = STATUS_OPTIONS.filter((s) => s !== support.status)
return (
<div className="section">
<button className="btn-back" onClick={() => navigate(-1)}> Back</button>
<div className="task-header">
<h2>🎫 {support.support_code ? <CopyableCode code={support.support_code} /> : `#${support.id}`}</h2>
<div className="task-meta">
<span className={`badge status-${support.status}`}>{support.status}</span>
<span className={`badge priority-${support.priority}`}>{support.priority}</span>
{support.project_code && <span className="text-dim">Project: {support.project_code}</span>}
{support.milestone_code && <span className="text-dim">Milestone: {support.milestone_code}</span>}
</div>
</div>
{message && (
<div
style={{
padding: '10px 12px',
marginBottom: '16px',
borderRadius: '8px',
background: message.toLowerCase().includes('fail') || message.toLowerCase().includes('error')
? 'rgba(239,68,68,.12)'
: 'rgba(16,185,129,.12)',
border: `1px solid ${
message.toLowerCase().includes('fail') || message.toLowerCase().includes('error')
? 'rgba(239,68,68,.35)'
: 'rgba(16,185,129,.35)'
}`,
}}
>
{message}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '20px', alignItems: 'start' }}>
{/* Main content */}
<div className="monitor-card">
{editMode ? (
<div className="task-create-form">
<label>
Title
<input
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
/>
</label>
<label>
Description
<textarea
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={6}
style={{ width: '100%' }}
/>
</label>
<label>
Priority
<select
value={editForm.priority}
onChange={(e) => setEditForm({ ...editForm, priority: e.target.value })}
>
{PRIORITY_OPTIONS.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn-primary" disabled={saving} onClick={handleSave}>
{saving ? 'Saving...' : 'Save'}
</button>
<button className="btn-back" onClick={() => setEditMode(false)}>Cancel</button>
</div>
</div>
) : (
<>
<h3>{support.title}</h3>
{support.description && (
<p style={{ whiteSpace: 'pre-wrap', marginTop: 12 }}>{support.description}</p>
)}
{!support.description && <p className="text-dim">No description provided.</p>}
</>
)}
</div>
{/* Sidebar */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Ownership */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Assigned To</h4>
{support.taken_by ? (
<div>
<span className="badge">{support.taken_by}</span>
{isMine && <span className="text-dim" style={{ marginLeft: 8 }}>(you)</span>}
</div>
) : (
<span className="text-dim">Unassigned</span>
)}
{canTake && !isMine && (
<button
className="btn-primary"
style={{ marginTop: 8, width: '100%' }}
disabled={saving}
onClick={handleTake}
>
Take
</button>
)}
</div>
{/* Transition */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Transition</h4>
<select
value={transitionStatus}
onChange={(e) => setTransitionStatus(e.target.value)}
style={{ width: '100%', marginBottom: 8 }}
>
<option value="">Select status...</option>
{availableTransitions.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<button
className="btn-primary"
style={{ width: '100%' }}
disabled={saving || !transitionStatus}
onClick={handleTransition}
>
Transition
</button>
</div>
{/* Actions */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Actions</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!editMode && (
<button className="btn-secondary" onClick={() => setEditMode(true)}>
Edit
</button>
)}
<button className="btn-danger" disabled={saving} onClick={handleDelete}>
Delete
</button>
</div>
</div>
{/* Info */}
<div className="monitor-card">
<h4 style={{ marginBottom: 8 }}>Info</h4>
<div className="text-dim" style={{ fontSize: '0.9em' }}>
<div>Created: {new Date(support.created_at).toLocaleString()}</div>
{support.updated_at && <div>Updated: {new Date(support.updated_at).toLocaleString()}</div>}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import type { Task, Comment, Project, ProjectMember, Milestone } from '@/types'
import dayjs from 'dayjs'
import { useAuth } from '@/hooks/useAuth'
import CreateTaskModal from '@/components/CreateTaskModal'
import CopyableCode from '@/components/CopyableCode'
export default function TaskDetailPage() {
const { id } = useParams()
@@ -120,7 +121,7 @@ export default function TaskDetailPage() {
<button className="btn-back" onClick={() => navigate('/tasks')}> Back</button>
<div className="task-header">
<h2>#{task.id} {task.title}</h2>
<h2>{task.task_code ? <><CopyableCode code={task.task_code} /> </> : `#${task.id} `}{task.title}</h2>
<div className="task-meta">
<span className={`badge status-${task.status}`}>{task.status}</span>
<span className={`badge priority-${task.priority}`}>{task.priority}</span>
@@ -261,7 +262,7 @@ export default function TaskDetailPage() {
<h3>Comments ({comments.length})</h3>
{comments.map((c) => (
<div className="comment" key={c.id}>
<div className="comment-meta">User #{c.author_id} · {dayjs(c.created_at).format('MM-DD HH:mm')}</div>
<div className="comment-meta">{c.author_username || `User #${c.author_id}`} · {dayjs(c.created_at).format('MM-DD HH:mm')}</div>
<p>{c.content}</p>
</div>
))}

View File

@@ -27,8 +27,8 @@ export default function TasksPage() {
useEffect(() => { fetchTasks() }, [page, statusFilter, priorityFilter])
const statusColors: Record<string, string> = {
open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
closed: '#6b7280', blocked: '#ef4444',
pending: '#9ca3af', open: '#3b82f6', undergoing: '#f59e0b',
completed: '#10b981', closed: '#6b7280',
}
return (
@@ -50,11 +50,11 @@ export default function TasksPage() {
<div className="filters">
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="undergoing">Undergoing</option>
<option value="completed">Completed</option>
<option value="closed">Closed</option>
<option value="blocked">Blocked</option>
</select>
</div>
@@ -64,8 +64,8 @@ export default function TasksPage() {
</thead>
<tbody>
{tasks.map((t) => (
<tr key={t.id} onClick={() => navigate(`/tasks/${t.id}`)} className="clickable">
<td>{t.id}</td>
<tr key={t.id} onClick={() => navigate(`/tasks/${t.task_code || t.id}`)} className="clickable">
<td>{t.task_code || t.id}</td>
<td className="task-title">{t.title}</td>
<td><span className="badge" style={{ backgroundColor: statusColors[t.status] || '#ccc' }}>{t.status}</span></td>
<td><span className={`badge priority-${t.priority}`}>{t.priority}</span></td>

View File

@@ -9,6 +9,11 @@ interface RoleOption {
description?: string | null
}
interface ApiKeyPerms {
can_reset_self: boolean
can_reset_any: boolean
}
export default function UsersPage() {
const { user } = useAuth()
const isAdmin = user?.is_admin === true
@@ -19,6 +24,8 @@ export default function UsersPage() {
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [apikeyPerms, setApikeyPerms] = useState<ApiKeyPerms>({ can_reset_self: false, can_reset_any: false })
const [generatedApiKey, setGeneratedApiKey] = useState<string | null>(null)
const [createForm, setCreateForm] = useState({
username: '',
@@ -51,6 +58,7 @@ export default function UsersPage() {
useEffect(() => {
if (!selectedUser) return
setGeneratedApiKey(null)
setEditForm({
email: selectedUser.email,
full_name: selectedUser.full_name || '',
@@ -71,10 +79,12 @@ export default function UsersPage() {
const fetchData = async () => {
try {
const [usersRes, rolesRes] = await Promise.all([
const [usersRes, rolesRes, apikeyRes] = await Promise.all([
api.get<User[]>('/users'),
api.get<RoleOption[]>('/roles'),
api.get<ApiKeyPerms>('/auth/me/apikey-permissions').catch(() => ({ data: { can_reset_self: false, can_reset_any: false } })),
])
setApikeyPerms(apikeyRes.data)
const assignableRoles = rolesRes.data
.filter((role) => role.name !== 'admin')
.sort((a, b) => a.name.localeCompare(b.name))
@@ -152,6 +162,29 @@ export default function UsersPage() {
}
}
const canResetApiKey = (targetUser: User) => {
if (apikeyPerms.can_reset_any) return true
if (apikeyPerms.can_reset_self && targetUser.id === user?.id) return true
return false
}
const handleResetApiKey = async () => {
if (!selectedUser) return
if (!confirm(`Reset API key for ${selectedUser.username}? The old key will be deactivated.`)) return
setSaving(true)
setMessage('')
setGeneratedApiKey(null)
try {
const { data } = await api.post(`/users/${selectedUser.id}/reset-apikey`)
setGeneratedApiKey(data.api_key)
setMessage('API key reset successfully. Copy it now — it will not be shown again.')
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to reset API key')
} finally {
setSaving(false)
}
}
const handleDeleteUser = async () => {
if (!selectedUser) return
if (!confirm(`Delete user ${selectedUser.username}? This cannot be undone.`)) return
@@ -274,7 +307,7 @@ export default function UsersPage() {
<button className="btn-primary" disabled={saving} onClick={handleSaveUser}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button className="btn-danger" disabled={saving || user?.id === selectedUser.id} onClick={handleDeleteUser}>
<button className="btn-danger" disabled={saving || user?.id === selectedUser.id || selectedUser.is_admin || selectedUser.username === 'acc-mgr'} onClick={handleDeleteUser}>
Delete
</button>
</div>
@@ -326,6 +359,28 @@ export default function UsersPage() {
Active
</label>
</div>
{canResetApiKey(selectedUser) && (
<div style={{ marginTop: '8px', padding: '12px', border: '1px solid var(--border)', borderRadius: '8px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>API Key</div>
<button className="btn-secondary" disabled={saving} onClick={handleResetApiKey}>
🔑 Reset API Key
</button>
{generatedApiKey && (
<div style={{ marginTop: 10, padding: '10px', background: 'rgba(16,185,129,.08)', border: '1px solid rgba(16,185,129,.35)', borderRadius: '6px', wordBreak: 'break-all', fontFamily: 'monospace', fontSize: '13px' }}>
<div style={{ marginBottom: 6, fontWeight: 600, fontFamily: 'inherit' }}>New API Key (copy now!):</div>
<code>{generatedApiKey}</code>
<button
className="btn-sm"
style={{ marginLeft: 8 }}
onClick={() => { navigator.clipboard.writeText(generatedApiKey); setMessage('API key copied to clipboard') }}
>
📋 Copy
</button>
</div>
)}
</div>
)}
</div>
</>
) : (

View File

@@ -27,12 +27,15 @@ export interface Project {
export interface ProjectMember {
id: number
user_id: number
username: string | null
full_name: string | null
project_id: number
role: string
}
export interface Task {
id: number
task_code: string | null
title: string
description: string | null
task_type: 'issue' | 'maintenance' | 'research' | 'review' | 'story' | 'test' | 'resolution' // P7.1: 'task' removed
@@ -58,12 +61,14 @@ export interface Comment {
content: string
task_id: number
author_id: number
author_username: string | null
created_at: string
updated_at: string | null
}
export interface Milestone {
id: number
milestone_code: string | null
title: string
description: string | null
status: 'open' | 'freeze' | 'undergoing' | 'completed' | 'closed'
@@ -130,6 +135,7 @@ export interface Propose {
status: 'open' | 'accepted' | 'rejected'
project_id: number
created_by_id: number | null
created_by_username: string | null
feat_task_id: string | null
created_at: string
updated_at: string | null