feat(P10): add Propose type, list page, detail page with accept/reject/reopen + sidebar link
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user