feat(P10): add Propose type, list page, detail page with accept/reject/reopen + sidebar link
This commit is contained in:
@@ -14,6 +14,8 @@ import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
|
|||||||
import NotificationsPage from '@/pages/NotificationsPage'
|
import NotificationsPage from '@/pages/NotificationsPage'
|
||||||
import RoleEditorPage from '@/pages/RoleEditorPage'
|
import RoleEditorPage from '@/pages/RoleEditorPage'
|
||||||
import MonitorPage from '@/pages/MonitorPage'
|
import MonitorPage from '@/pages/MonitorPage'
|
||||||
|
import ProposesPage from '@/pages/ProposesPage'
|
||||||
|
import ProposeDetailPage from '@/pages/ProposeDetailPage'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
|
||||||
@@ -90,6 +92,8 @@ export default function App() {
|
|||||||
<Route path="/projects/:id" element={<ProjectDetailPage />} />
|
<Route path="/projects/:id" element={<ProjectDetailPage />} />
|
||||||
<Route path="/milestones" element={<MilestonesPage />} />
|
<Route path="/milestones" element={<MilestonesPage />} />
|
||||||
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
||||||
|
<Route path="/proposes" element={<ProposesPage />} />
|
||||||
|
<Route path="/proposes/:id" element={<ProposeDetailPage />} />
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
<Route path="/roles" element={<RoleEditorPage />} />
|
<Route path="/roles" element={<RoleEditorPage />} />
|
||||||
<Route path="/monitor" element={<MonitorPage />} />
|
<Route path="/monitor" element={<MonitorPage />} />
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default function Sidebar({ user, onLogout }: Props) {
|
|||||||
const links = user ? [
|
const links = user ? [
|
||||||
{ to: '/', icon: '📊', label: 'Dashboard' },
|
{ to: '/', icon: '📊', label: 'Dashboard' },
|
||||||
{ to: '/projects', icon: '📁', label: 'Projects' },
|
{ to: '/projects', icon: '📁', label: 'Projects' },
|
||||||
|
{ to: '/proposes', icon: '💡', label: 'Proposes' },
|
||||||
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
|
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
|
||||||
{ to: '/monitor', icon: '📡', label: 'Monitor' },
|
{ to: '/monitor', icon: '📡', label: 'Monitor' },
|
||||||
...(user.is_admin ? [{ to: '/roles', icon: '🔐', label: 'Roles' }] : []),
|
...(user.is_admin ? [{ to: '/roles', icon: '🔐', label: 'Roles' }] : []),
|
||||||
|
|||||||
190
src/pages/ProposeDetailPage.tsx
Normal file
190
src/pages/ProposeDetailPage.tsx
Normal file
@@ -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<Propose | null>(null)
|
||||||
|
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||||
|
const [showAccept, setShowAccept] = useState(false)
|
||||||
|
const [selectedMilestone, setSelectedMilestone] = useState<number | ''>('')
|
||||||
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchPropose = () => {
|
||||||
|
if (!projectId) return
|
||||||
|
api.get<Propose>(`/projects/${projectId}/proposes/${id}`).then(({ data }) => setPropose(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPropose()
|
||||||
|
}, [id, projectId])
|
||||||
|
|
||||||
|
const loadMilestones = () => {
|
||||||
|
if (!projectId) return
|
||||||
|
api.get<Milestone[]>(`/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 <div className="loading">Loading...</div>
|
||||||
|
|
||||||
|
const statusBadgeClass = (s: string) => {
|
||||||
|
if (s === 'open') return 'status-open'
|
||||||
|
if (s === 'accepted') return 'status-completed'
|
||||||
|
if (s === 'rejected') return 'status-closed'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-detail">
|
||||||
|
<button className="btn-back" onClick={() => navigate(-1)}>← Back</button>
|
||||||
|
|
||||||
|
<div className="task-header">
|
||||||
|
<h2>
|
||||||
|
💡 {propose.title}
|
||||||
|
{propose.propose_code && <span className="badge" style={{ marginLeft: 8 }}>{propose.propose_code}</span>}
|
||||||
|
</h2>
|
||||||
|
<span className={`badge ${statusBadgeClass(propose.status)}`} style={{ fontSize: '1rem' }}>
|
||||||
|
{propose.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-message" style={{ color: 'var(--danger)', marginBottom: 12 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h3>Details</h3>
|
||||||
|
<div className="detail-grid">
|
||||||
|
<div><strong>Propose Code:</strong> {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:</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h3>Description</h3>
|
||||||
|
<p style={{ whiteSpace: 'pre-wrap' }}>{propose.description || 'No description'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="section" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{propose.status === 'open' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => { loadMilestones(); setShowAccept(true) }}
|
||||||
|
>
|
||||||
|
✅ Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={handleReject}
|
||||||
|
>
|
||||||
|
❌ Reject
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{propose.status === 'accepted' && propose.feat_task_id && (
|
||||||
|
<button className="btn-transition" onClick={() => navigate(`/tasks/${propose.feat_task_id}`)}>
|
||||||
|
📋 View Generated Task
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{propose.status === 'rejected' && (
|
||||||
|
<button
|
||||||
|
className="btn-transition"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={handleReopen}
|
||||||
|
>
|
||||||
|
🔄 Reopen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accept modal with milestone selector */}
|
||||||
|
{showAccept && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowAccept(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Accept Propose</h3>
|
||||||
|
<p>Select an <strong>open</strong> milestone to create a feature story task in:</p>
|
||||||
|
<select
|
||||||
|
value={selectedMilestone}
|
||||||
|
onChange={(e) => setSelectedMilestone(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">— Select milestone —</option>
|
||||||
|
{milestones.map((ms) => (
|
||||||
|
<option key={ms.id} value={ms.id}>{ms.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{milestones.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--danger)', marginTop: 8 }}>No open milestones available.</p>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleAccept}
|
||||||
|
disabled={!selectedMilestone || actionLoading}
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Accepting...' : 'Confirm Accept'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-back" onClick={() => setShowAccept(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/pages/ProposesPage.tsx
Normal file
110
src/pages/ProposesPage.tsx
Normal file
@@ -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<Propose[]>([])
|
||||||
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
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<Propose[]>(`/projects/${projectFilter}/proposes`).then(({ data }) => setProposes(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Project[]>('/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 (
|
||||||
|
<div className="milestones-page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h2>💡 Proposes ({proposes.length})</h2>
|
||||||
|
<button className="btn-primary" disabled={!projectFilter} onClick={() => setShowCreate(true)}>
|
||||||
|
+ New Propose
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filters">
|
||||||
|
<select value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}>
|
||||||
|
<option value="">Select a project</option>
|
||||||
|
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!projectFilter && <p className="empty">Please select a project to view proposes.</p>}
|
||||||
|
|
||||||
|
<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 className="milestone-card-header">
|
||||||
|
<span className={`badge ${statusBadgeClass(pr.status)}`}>{pr.status}</span>
|
||||||
|
{pr.propose_code && <span className="badge">{pr.propose_code}</span>}
|
||||||
|
<h3>{pr.title}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="project-desc">{pr.description || 'No description'}</p>
|
||||||
|
<div className="project-meta">
|
||||||
|
{pr.feat_task_id && <span>Task: {pr.feat_task_id}</span>}
|
||||||
|
<span>Created {dayjs(pr.created_at).format('YYYY-MM-DD')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{projectFilter && proposes.length === 0 && <p className="empty">No proposes</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>New Propose</h3>
|
||||||
|
<label>Title</label>
|
||||||
|
<input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} placeholder="Propose title" />
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" rows={4} />
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn-primary" onClick={handleCreate} disabled={creating || !newTitle.trim()}>
|
||||||
|
{creating ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-back" onClick={() => setShowCreate(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -120,6 +120,19 @@ export interface DashboardStats {
|
|||||||
recent_tasks: Task[]
|
recent_tasks: Task[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Propose {
|
||||||
|
id: number
|
||||||
|
propose_code: string | null
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: 'open' | 'accepted' | 'rejected'
|
||||||
|
project_id: number
|
||||||
|
created_by_id: number | null
|
||||||
|
feat_task_id: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
token_type: string
|
token_type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user