From 35e7d3a141b69b03beaee1760b268d62cad01553 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 17 Mar 2026 05:03:49 +0000 Subject: [PATCH] feat(P10): add Propose type, list page, detail page with accept/reject/reopen + sidebar link --- src/App.tsx | 4 + src/components/Sidebar.tsx | 1 + src/pages/ProposeDetailPage.tsx | 190 ++++++++++++++++++++++++++++++++ src/pages/ProposesPage.tsx | 110 ++++++++++++++++++ src/types/index.ts | 13 +++ 5 files changed, 318 insertions(+) create mode 100644 src/pages/ProposeDetailPage.tsx create mode 100644 src/pages/ProposesPage.tsx 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/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/pages/ProposeDetailPage.tsx b/src/pages/ProposeDetailPage.tsx new file mode 100644 index 0000000..f9ad52e --- /dev/null +++ b/src/pages/ProposeDetailPage.tsx @@ -0,0 +1,190 @@ +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('') + + 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 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' && ( + + )} +
+ + {/* Accept modal with milestone selector */} + {showAccept && ( +
setShowAccept(false)}> +
e.stopPropagation()}> +

Accept Propose

+

Select an open milestone to create a feature story task in:

+ + {milestones.length === 0 && ( +

No open milestones available.

+ )} +
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/ProposesPage.tsx b/src/pages/ProposesPage.tsx new file mode 100644 index 0000000..c9f16ea --- /dev/null +++ b/src/pages/ProposesPage.tsx @@ -0,0 +1,110 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import api from '@/services/api' +import type { Propose, Project } from '@/types' +import dayjs from 'dayjs' + +export default function ProposesPage() { + const [proposes, setProposes] = useState([]) + const [projects, setProjects] = useState([]) + const [projectFilter, setProjectFilter] = useState('') + const [showCreate, setShowCreate] = useState(false) + const [newTitle, setNewTitle] = useState('') + const [newDesc, setNewDesc] = useState('') + const [creating, setCreating] = useState(false) + const navigate = useNavigate() + + const fetchProposes = () => { + if (!projectFilter) { + setProposes([]) + return + } + api.get(`/projects/${projectFilter}/proposes`).then(({ data }) => setProposes(data)) + } + + useEffect(() => { + api.get('/projects').then(({ data }) => setProjects(data)) + }, []) + + useEffect(() => { fetchProposes() }, [projectFilter]) + + const handleCreate = async () => { + if (!newTitle.trim() || !projectFilter) return + setCreating(true) + try { + await api.post(`/projects/${projectFilter}/proposes`, { + title: newTitle.trim(), + description: newDesc.trim() || null, + }) + setNewTitle('') + setNewDesc('') + setShowCreate(false) + fetchProposes() + } finally { + setCreating(false) + } + } + + const statusBadgeClass = (s: string) => { + if (s === 'open') return 'status-open' + if (s === 'accepted') return 'status-completed' + if (s === 'rejected') return 'status-closed' + return '' + } + + return ( +
+
+

💡 Proposes ({proposes.length})

+ +
+ +
+ +
+ + {!projectFilter &&

Please select a project to view proposes.

} + +
+ {proposes.map((pr) => ( +
navigate(`/proposes/${pr.id}?project_id=${pr.project_id}`)}> +
+ {pr.status} + {pr.propose_code && {pr.propose_code}} +

{pr.title}

+
+

{pr.description || 'No description'}

+
+ {pr.feat_task_id && Task: {pr.feat_task_id}} + Created {dayjs(pr.created_at).format('YYYY-MM-DD')} +
+
+ ))} + {projectFilter && proposes.length === 0 &&

No proposes

} +
+ + {showCreate && ( +
setShowCreate(false)}> +
e.stopPropagation()}> +

New Propose

+ + setNewTitle(e.target.value)} placeholder="Propose title" /> + +