Merge pull request 'Merge dev-2026-03-22 into main' (#10) from dev-2026-03-22 into main
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
26
src/App.tsx
26
src/App.tsx
@@ -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 />} />
|
||||
|
||||
45
src/components/CopyableCode.tsx
Normal file
45
src/components/CopyableCode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
322
src/pages/MeetingDetailPage.tsx
Normal file
322
src/pages/MeetingDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
299
src/pages/SupportDetailPage.tsx
Normal file
299
src/pages/SupportDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user