Files
HarborForge.Frontend/src/pages/MilestoneDetailPage.tsx

366 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '@/services/api'
import type { Milestone, MilestoneProgress, Task, Project, ProjectMember } from '@/types'
import dayjs from 'dayjs'
import CreateTaskModal from '@/components/CreateTaskModal'
import MilestoneFormModal from '@/components/MilestoneFormModal'
import { useAuth } from '@/hooks/useAuth'
interface MilestoneTask {
id: number
title: string
description?: string
status: string
task_code?: string
task_status?: string
estimated_effort?: number
estimated_working_time?: string
started_on?: string
finished_on?: string
assignee_id?: number
created_at: string
}
export default function MilestoneDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const [milestone, setMilestone] = useState<Milestone | null>(null)
const [project, setProject] = useState<Project | null>(null)
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 [activeTab, setActiveTab] = useState<'tasks' | 'supports' | 'meetings'>('tasks')
const [showCreateTask, setShowCreateTask] = useState(false)
const [showEditMilestone, setShowEditMilestone] = useState(false)
const [showCreateSupport, setShowCreateSupport] = useState(false)
const [showCreateMeeting, setShowCreateMeeting] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newDesc, setNewDesc] = useState('')
const [projectCode, setProjectCode] = useState('')
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const [closeReason, setCloseReason] = useState('')
const fetchMilestone = () => {
api.get<Milestone>(`/milestones/${id}`).then(({ data }) => {
setMilestone(data)
if (data.project_id) {
api.get<Project>(`/projects/${data.project_id}`).then(({ data: proj }) => {
setProject(proj)
setProjectCode(proj.project_code || '')
})
api.get<ProjectMember[]>(`/projects/${data.project_id}/members`).then(({ data }) => setMembers(data)).catch(() => {})
}
})
api.get<MilestoneProgress>(`/milestones/${id}/progress`).then(({ data }) => setProgress(data)).catch(() => {})
}
useEffect(() => {
fetchMilestone()
}, [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(() => {})
}
useEffect(() => {
refreshMilestoneItems()
}, [projectCode, id])
const createItem = async (type: 'supports' | 'meetings') => {
if (!newTitle.trim() || !projectCode) return
const payload = {
title: newTitle,
description: newDesc || null,
}
await api.post(`/${type}/${projectCode}/${id}`, payload)
setNewTitle('')
setNewDesc('')
setShowCreateSupport(false)
setShowCreateMeeting(false)
refreshMilestoneItems()
}
const currentMemberRole = useMemo(
() => members.find((m) => m.user_id === user?.id)?.role,
[members, user?.id]
)
const canEditMilestone = Boolean(milestone && project && user && (
user.is_admin ||
user.id === project.owner_id ||
user.id === milestone.created_by_id ||
currentMemberRole === 'admin'
))
const msStatus = milestone?.status
const isUndergoing = msStatus === 'undergoing'
const isTerminal = msStatus === 'completed' || msStatus === 'closed'
// --- Milestone action handlers (P8.2) ---
const performAction = async (action: string, body?: Record<string, unknown>) => {
if (!milestone || !project) return
setActionLoading(action)
setActionError(null)
try {
await api.post(`/projects/${project.id}/milestones/${milestone.id}/actions/${action}`, body ?? {})
fetchMilestone()
refreshMilestoneItems()
} catch (err: any) {
const detail = err?.response?.data?.detail
setActionError(typeof detail === 'string' ? detail : `${action} failed`)
} finally {
setActionLoading(null)
}
}
const handleFreeze = () => performAction('freeze')
const handleStart = () => performAction('start')
const handleClose = () => {
performAction('close', closeReason ? { reason: closeReason } : {})
setShowCloseConfirm(false)
setCloseReason('')
}
if (!milestone) return <div className="loading">Loading...</div>
const renderTaskRow = (t: MilestoneTask) => (
<tr key={t.id} className="clickable" onClick={() => navigate(`/tasks/${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>
<td>{t.estimated_effort || '-'}</td>
<td>{t.estimated_working_time || '-'}</td>
</tr>
)
return (
<div className="milestone-detail">
<button className="btn-back" onClick={() => navigate('/milestones')}> Back to Milestones</button>
<div className="task-header">
<h2>🏁 {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>}
{milestone.planned_release_date && <span className="text-dim">Planned Release: {dayjs(milestone.planned_release_date).format('YYYY-MM-DD')}</span>}
{milestone.started_at && <span className="text-dim">Started: {dayjs(milestone.started_at).format('YYYY-MM-DD HH:mm')}</span>}
</div>
{canEditMilestone && msStatus === 'open' && <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditMilestone(true)}>Edit Milestone</button>}
{canEditMilestone && (msStatus === 'freeze' || msStatus === 'undergoing') && (
<span className="text-dim" style={{ marginTop: 8, display: 'inline-block' }}>
Milestone is {msStatus} scope fields are locked
</span>
)}
{/* Milestone status action buttons (P8.2) */}
{!isTerminal && (
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{msStatus === 'open' && (
<button
className="btn-primary"
disabled={actionLoading === 'freeze'}
onClick={handleFreeze}
>
{actionLoading === 'freeze' ? '⏳ Freezing...' : '🧊 Freeze'}
</button>
)}
{msStatus === 'freeze' && (
<button
className="btn-primary"
disabled={actionLoading === 'start'}
onClick={handleStart}
>
{actionLoading === 'start' ? '⏳ Starting...' : '▶️ Start'}
</button>
)}
{(msStatus === 'open' || msStatus === 'freeze' || msStatus === 'undergoing') && (
<>
{!showCloseConfirm ? (
<button
className="btn-transition"
style={{ color: 'var(--color-danger, #e74c3c)' }}
onClick={() => setShowCloseConfirm(true)}
>
Close
</button>
) : (
<div className="card" style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 12px' }}>
<input
placeholder="Reason (optional)"
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
style={{ minWidth: 180 }}
/>
<button
className="btn-primary"
style={{ backgroundColor: 'var(--color-danger, #e74c3c)' }}
disabled={actionLoading === 'close'}
onClick={handleClose}
>
{actionLoading === 'close' ? 'Closing...' : 'Confirm Close'}
</button>
<button className="btn-back" onClick={() => { setShowCloseConfirm(false); setCloseReason('') }}>Cancel</button>
</div>
)}
</>
)}
</div>
)}
{actionError && <p style={{ color: 'var(--color-danger, #e74c3c)', marginTop: 4 }}> {actionError}</p>}
</div>
{milestone.description && (
<div className="section">
<h3>Description</h3>
<p>{milestone.description}</p>
</div>
)}
{progress && (
<div className="section">
<h3>Progress (Tasks: {progress.completed}/{progress.total})</h3>
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress.progress_pct}%` }}>
{progress.progress_pct.toFixed(0)}%
</div>
</div>
{progress.time_progress_pct !== null && (
<>
<p className="text-dim" style={{ marginTop: 8 }}>Time Progress</p>
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress.time_progress_pct}%`, backgroundColor: 'var(--color-accent)' }}>
{progress.time_progress_pct.toFixed(0)}%
</div>
</div>
</>
)}
</div>
)}
<div className="section">
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{!isTerminal && !isUndergoing && canEditMilestone && (
<>
<button className="btn-primary" onClick={() => { setActiveTab('tasks'); setShowCreateTask(true) }}>+ Create Task</button>
<button className="btn-primary" onClick={() => { setActiveTab('supports'); setShowCreateSupport(true) }}>+ Create Support</button>
<button className="btn-primary" onClick={() => { setActiveTab('meetings'); setShowCreateMeeting(true) }}>+ Schedule Meeting</button>
</>
)}
{(isUndergoing || isTerminal) && <span className="text-dim">{isTerminal ? `Milestone is ${msStatus}` : 'Milestone is undergoing'} cannot add new items</span>}
</div>
<MilestoneFormModal
isOpen={showEditMilestone}
onClose={() => setShowEditMilestone(false)}
milestone={milestone}
lockProject
onSaved={(data) => {
setMilestone(data)
fetchMilestone()
}}
/>
<CreateTaskModal
isOpen={showCreateTask}
onClose={() => setShowCreateTask(false)}
initialProjectId={milestone.project_id}
initialMilestoneId={milestone.id}
lockProject
lockMilestone
onCreated={() => {
setActiveTab('tasks')
refreshMilestoneItems()
}}
/>
{(showCreateSupport || showCreateMeeting) && (
<div className="card" style={{ marginBottom: 16 }}>
<input
placeholder="Title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
style={{ marginBottom: 8 }}
/>
<textarea
placeholder="Description (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
style={{ marginBottom: 8, width: '100%' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn-primary" onClick={() => createItem(activeTab as 'supports' | 'meetings')}>Create</button>
<button className="btn-back" onClick={() => { setShowCreateSupport(false); setShowCreateMeeting(false) }}>Cancel</button>
</div>
</div>
)}
<div className="tabs">
<button className={`tab ${activeTab === 'tasks' ? 'active' : ''}`} onClick={() => setActiveTab('tasks')}>
Tasks ({tasks.length})
</button>
<button className={`tab ${activeTab === 'supports' ? 'active' : ''}`} onClick={() => setActiveTab('supports')}>
Supports ({supports.length})
</button>
<button className={`tab ${activeTab === 'meetings' ? 'active' : ''}`} onClick={() => setActiveTab('meetings')}>
Meetings ({meetings.length})
</button>
</div>
<div className="tab-content">
{activeTab === 'tasks' && (
<table>
<thead><tr><th>Task Code</th><th>Title</th><th>Status</th><th>Effort</th><th>Est. Time</th></tr></thead>
<tbody>
{tasks.map(renderTaskRow)}
{tasks.length === 0 && <tr><td colSpan={5} className="empty">No tasks</td></tr>}
</tbody>
</table>
)}
{activeTab === 'supports' && (
<table>
<thead><tr><th>#</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>
<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>
</tr>
))}
{supports.length === 0 && <tr><td colSpan={4} className="empty">No support requests</td></tr>}
</tbody>
</table>
)}
{activeTab === 'meetings' && (
<table>
<thead><tr><th>#</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>
<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>
</tr>
))}
{meetings.length === 0 && <tr><td colSpan={4} className="empty">No meetings</td></tr>}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}