diff --git a/src/App.tsx b/src/App.tsx index 6d38b45..4aab74c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,8 @@ import MilestoneDetailPage from '@/pages/MilestoneDetailPage' import NotificationsPage from '@/pages/NotificationsPage' import RoleEditorPage from '@/pages/RoleEditorPage' import MonitorPage from '@/pages/MonitorPage' +import ProposesPage from '@/pages/ProposesPage' +import ProposeDetailPage from '@/pages/ProposeDetailPage' import axios from 'axios' const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080 @@ -90,6 +92,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/CreateTaskModal.tsx b/src/components/CreateTaskModal.tsx index 4a59963..61ed735 100644 --- a/src/components/CreateTaskModal.tsx +++ b/src/components/CreateTaskModal.tsx @@ -3,11 +3,11 @@ import api from '@/services/api' import type { Milestone, Project, Task } from '@/types' const TASK_TYPES = [ - { value: 'story', label: 'Story', subtypes: ['feature', 'improvement', 'refactor'] }, + { value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from propose accept { value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] }, - { value: 'task', label: 'Task', subtypes: ['defect'] }, + // P7.1: 'task' type removed — defect subtype migrated to issue/defect { value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] }, - { value: 'maintenance', label: 'Maintenance', subtypes: ['deploy', 'release'] }, + { value: 'maintenance', label: 'Maintenance', subtypes: ['deploy'] }, // P9.6: 'release' removed — controlled via milestone flow { value: 'research', label: 'Research', subtypes: [] }, { value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] }, { value: 'resolution', label: 'Resolution', subtypes: [] }, @@ -42,7 +42,7 @@ const makeInitialForm = (projectId = 0, milestoneId = 0): FormState => ({ description: '', project_id: projectId, milestone_id: milestoneId, - task_type: 'task', + task_type: 'issue', // P7.1: default changed from 'task' to 'issue' task_subtype: '', priority: 'medium', tags: '', diff --git a/src/components/MilestoneFormModal.tsx b/src/components/MilestoneFormModal.tsx index 00e6ecb..7147dc1 100644 --- a/src/components/MilestoneFormModal.tsx +++ b/src/components/MilestoneFormModal.tsx @@ -125,9 +125,9 @@ export default function MilestoneFormModal({ isOpen, onClose, onSaved, milestone Status diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3395448..7868417 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -32,6 +32,7 @@ export default function Sidebar({ user, onLogout }: Props) { const links = user ? [ { to: '/', icon: '📊', label: 'Dashboard' }, { to: '/projects', icon: '📁', label: 'Projects' }, + { to: '/proposes', icon: '💡', label: 'Proposes' }, { to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') }, { to: '/monitor', icon: '📡', label: 'Monitor' }, ...(user.is_admin ? [{ to: '/roles', icon: '🔐', label: 'Roles' }] : []), diff --git a/src/index.css b/src/index.css index f474997..e580b58 100644 --- a/src/index.css +++ b/src/index.css @@ -72,6 +72,9 @@ tr.clickable:hover { background: var(--bg-hover); } .status-resolved { background: #10b981; } .status-closed { background: #6b7280; } .status-blocked { background: #ef4444; } +.status-freeze { background: #8b5cf6; } +.status-undergoing { background: #f59e0b; } +.status-completed { background: #10b981; } .priority-low { background: #6b7280; } .priority-medium { background: #3b82f6; } .priority-high { background: #f59e0b; } diff --git a/src/pages/CreateTaskPage.tsx b/src/pages/CreateTaskPage.tsx index c074dd4..8a1c82e 100644 --- a/src/pages/CreateTaskPage.tsx +++ b/src/pages/CreateTaskPage.tsx @@ -4,11 +4,11 @@ import api from '@/services/api' import type { Project, Milestone } from '@/types' const TASK_TYPES = [ - { value: 'story', label: 'Story', subtypes: ['feature', 'improvement', 'refactor'] }, + { value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from propose accept { value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] }, - { value: 'task', label: 'Task', subtypes: ['defect'] }, + // P7.1: 'task' type removed — defect subtype migrated to issue/defect { value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] }, - { value: 'maintenance', label: 'Maintenance', subtypes: ['deploy', 'release'] }, + { value: 'maintenance', label: 'Maintenance', subtypes: ['deploy'] }, // P9.6: 'release' removed — controlled via milestone flow { value: 'research', label: 'Research', subtypes: [] }, { value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] }, { value: 'resolution', label: 'Resolution', subtypes: [] }, @@ -19,7 +19,7 @@ export default function CreateTaskPage() { const [projects, setProjects] = useState([]) const [milestones, setMilestones] = useState([]) const [form, setForm] = useState({ - title: '', description: '', project_id: 0, milestone_id: 0, task_type: 'task', + title: '', description: '', project_id: 0, milestone_id: 0, task_type: 'issue', // P7.1: default changed from 'task' to 'issue' task_subtype: '', priority: 'medium', tags: '', reporter_id: 1, }) diff --git a/src/pages/MilestoneDetailPage.tsx b/src/pages/MilestoneDetailPage.tsx index fe85746..f5e27c5 100644 --- a/src/pages/MilestoneDetailPage.tsx +++ b/src/pages/MilestoneDetailPage.tsx @@ -41,6 +41,11 @@ export default function MilestoneDetailPage() { const [newTitle, setNewTitle] = useState('') const [newDesc, setNewDesc] = useState('') const [projectCode, setProjectCode] = useState('') + const [actionLoading, setActionLoading] = useState(null) + const [actionError, setActionError] = useState(null) + const [showCloseConfirm, setShowCloseConfirm] = useState(false) + const [closeReason, setCloseReason] = useState('') + const [preflight, setPreflight] = useState<{ freeze?: { allowed: boolean; reason: string | null }; start?: { allowed: boolean; reason: string | null } } | null>(null) const fetchMilestone = () => { api.get(`/milestones/${id}`).then(({ data }) => { @@ -51,11 +56,18 @@ export default function MilestoneDetailPage() { setProjectCode(proj.project_code || '') }) api.get(`/projects/${data.project_id}/members`).then(({ data }) => setMembers(data)).catch(() => {}) + fetchPreflight(data.project_id) } }) api.get(`/milestones/${id}/progress`).then(({ data }) => setProgress(data)).catch(() => {}) } + const fetchPreflight = (projectId: number) => { + api.get(`/projects/${projectId}/milestones/${id}/actions/preflight`) + .then(({ data }) => setPreflight(data)) + .catch(() => setPreflight(null)) + } + useEffect(() => { fetchMilestone() }, [id]) @@ -96,7 +108,35 @@ export default function MilestoneDetailPage() { currentMemberRole === 'admin' )) - const isProgressing = milestone?.status === 'progressing' + 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) => { + if (!milestone || !project) return + setActionLoading(action) + setActionError(null) + try { + await api.post(`/projects/${project.id}/milestones/${milestone.id}/actions/${action}`, body ?? {}) + fetchMilestone() + refreshMilestoneItems() + fetchPreflight(project.id) + } 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
Loading...
@@ -117,11 +157,89 @@ export default function MilestoneDetailPage() {

🏁 {milestone.title}

- {milestone.status} + {milestone.status} {milestone.due_date && Due {dayjs(milestone.due_date).format('YYYY-MM-DD')}} {milestone.planned_release_date && Planned Release: {dayjs(milestone.planned_release_date).format('YYYY-MM-DD')}} + {milestone.started_at && Started: {dayjs(milestone.started_at).format('YYYY-MM-DD HH:mm')}}
- {canEditMilestone && } + {canEditMilestone && msStatus === 'open' && } + {canEditMilestone && (msStatus === 'freeze' || msStatus === 'undergoing') && ( + + ⚠ Milestone is {msStatus} — scope fields are locked + + )} + + {/* Milestone status action buttons (P8.2) */} + {!isTerminal && ( +
+ {msStatus === 'open' && ( + + + {preflight?.freeze?.allowed === false && ( + + ⚠ {preflight.freeze.reason} + + )} + + )} + {msStatus === 'freeze' && ( + + + {preflight?.start?.allowed === false && ( + + ⚠ {preflight.start.reason} + + )} + + )} + {(msStatus === 'open' || msStatus === 'freeze' || msStatus === 'undergoing') && ( + <> + {!showCloseConfirm ? ( + + ) : ( +
+ setCloseReason(e.target.value)} + style={{ minWidth: 180 }} + /> + + +
+ )} + + )} +
+ )} + {actionError &&

⚠️ {actionError}

}
{milestone.description && ( @@ -154,14 +272,14 @@ export default function MilestoneDetailPage() {
- {!isProgressing && canEditMilestone && ( + {!isTerminal && !isUndergoing && canEditMilestone && ( <> )} - {isProgressing && Milestone is in progress - cannot add new items} + {(isUndergoing || isTerminal) && {isTerminal ? `Milestone is ${msStatus}` : 'Milestone is undergoing'} — cannot add new items}
(
navigate(`/milestones/${ms.id}`)}>
- {ms.status} + {ms.status}

{ms.title}

{ms.description || 'No description'}

diff --git a/src/pages/ProposeDetailPage.tsx b/src/pages/ProposeDetailPage.tsx new file mode 100644 index 0000000..5f5b7c0 --- /dev/null +++ b/src/pages/ProposeDetailPage.tsx @@ -0,0 +1,265 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, useSearchParams } from 'react-router-dom' +import api from '@/services/api' +import type { Propose, Milestone } from '@/types' +import dayjs from 'dayjs' + +export default function ProposeDetailPage() { + const { id } = useParams() + const [searchParams] = useSearchParams() + const projectId = searchParams.get('project_id') + const navigate = useNavigate() + + const [propose, setPropose] = useState(null) + const [milestones, setMilestones] = useState([]) + const [showAccept, setShowAccept] = useState(false) + const [selectedMilestone, setSelectedMilestone] = useState('') + const [actionLoading, setActionLoading] = useState(false) + const [error, setError] = useState('') + + // Edit state (P10.7) + const [showEdit, setShowEdit] = useState(false) + const [editTitle, setEditTitle] = useState('') + const [editDescription, setEditDescription] = useState('') + const [editLoading, setEditLoading] = useState(false) + + const fetchPropose = () => { + if (!projectId) return + api.get(`/projects/${projectId}/proposes/${id}`).then(({ data }) => setPropose(data)) + } + + useEffect(() => { + fetchPropose() + }, [id, projectId]) + + const loadMilestones = () => { + if (!projectId) return + api.get(`/milestones?project_id=${projectId}`) + .then(({ data }) => setMilestones(data.filter((m) => m.status === 'open'))) + } + + const handleAccept = async () => { + if (!selectedMilestone || !projectId) return + setActionLoading(true) + setError('') + try { + await api.post(`/projects/${projectId}/proposes/${id}/accept`, { milestone_id: selectedMilestone }) + setShowAccept(false) + fetchPropose() + } catch (err: any) { + setError(err.response?.data?.detail || 'Accept failed') + } finally { + setActionLoading(false) + } + } + + const handleReject = async () => { + if (!projectId) return + const reason = prompt('Reject reason (optional):') + setActionLoading(true) + setError('') + try { + await api.post(`/projects/${projectId}/proposes/${id}/reject`, { reason: reason || undefined }) + fetchPropose() + } catch (err: any) { + setError(err.response?.data?.detail || 'Reject failed') + } finally { + setActionLoading(false) + } + } + + const openEditModal = () => { + if (!propose) return + setEditTitle(propose.title) + setEditDescription(propose.description || '') + setError('') + setShowEdit(true) + } + + const handleEdit = async () => { + if (!projectId) return + setEditLoading(true) + setError('') + try { + await api.patch(`/projects/${projectId}/proposes/${id}`, { + title: editTitle, + description: editDescription, + }) + setShowEdit(false) + fetchPropose() + } catch (err: any) { + setError(err.response?.data?.detail || 'Update failed') + } finally { + setEditLoading(false) + } + } + + const handleReopen = async () => { + if (!projectId) return + setActionLoading(true) + setError('') + try { + await api.post(`/projects/${projectId}/proposes/${id}/reopen`) + fetchPropose() + } catch (err: any) { + setError(err.response?.data?.detail || 'Reopen failed') + } finally { + setActionLoading(false) + } + } + + if (!propose) return
Loading...
+ + const statusBadgeClass = (s: string) => { + if (s === 'open') return 'status-open' + if (s === 'accepted') return 'status-completed' + if (s === 'rejected') return 'status-closed' + return '' + } + + return ( +
+ + +
+

+ 💡 {propose.title} + {propose.propose_code && {propose.propose_code}} +

+ + {propose.status} + +
+ + {error &&
{error}
} + +
+

Details

+
+
Propose Code: {propose.propose_code || '—'}
+
Status: {propose.status}
+
Created By: User #{propose.created_by_id || '—'}
+
Created: {dayjs(propose.created_at).format('YYYY-MM-DD HH:mm')}
+
Updated: {propose.updated_at ? dayjs(propose.updated_at).format('YYYY-MM-DD HH:mm') : '—'}
+
Feature Task: {propose.feat_task_id || '—'}
+
+
+ +
+

Description

+

{propose.description || 'No description'}

+
+ + {/* Action buttons */} +
+ {propose.status === 'open' && ( + <> + + + + + )} + {propose.status === 'accepted' && propose.feat_task_id && ( + + )} + {propose.status === 'rejected' && ( + + )} +
+ + {/* Edit modal (P10.7 — only reachable when open) */} + {showEdit && ( +
setShowEdit(false)}> +
e.stopPropagation()}> +

Edit Propose

+ +