- Rename ProposesPage -> ProposalsPage, ProposeDetailPage -> ProposalDetailPage - Update Propose type to Proposal (keep Propose as deprecated alias) - Add GeneratedTask type for accept results - Switch API calls from /proposes to /proposals (canonical) - Update sidebar label: Proposes -> Proposals - Update routes: /proposals (+ legacy /proposes compat) - Update all UI text: Propose -> Proposal - Remove feat_task_id display, add generated_tasks section - Clean up propose references in comments
284 lines
9.7 KiB
TypeScript
284 lines
9.7 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||
import api from '@/services/api'
|
||
import type { Proposal, Milestone } from '@/types'
|
||
import dayjs from 'dayjs'
|
||
import CopyableCode from '@/components/CopyableCode'
|
||
|
||
export default function ProposalDetailPage() {
|
||
const { id } = useParams()
|
||
const [searchParams] = useSearchParams()
|
||
const projectId = searchParams.get('project_id')
|
||
const navigate = useNavigate()
|
||
|
||
const [proposal, setProposal] = useState<Proposal | 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('')
|
||
|
||
// Edit state
|
||
const [showEdit, setShowEdit] = useState(false)
|
||
const [editTitle, setEditTitle] = useState('')
|
||
const [editDescription, setEditDescription] = useState('')
|
||
const [editLoading, setEditLoading] = useState(false)
|
||
|
||
const fetchProposal = () => {
|
||
if (!projectId) return
|
||
api.get<Proposal>(`/projects/${projectId}/proposals/${id}`).then(({ data }) => setProposal(data))
|
||
}
|
||
|
||
useEffect(() => {
|
||
fetchProposal()
|
||
}, [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}/proposals/${id}/accept`, { milestone_id: selectedMilestone })
|
||
setShowAccept(false)
|
||
fetchProposal()
|
||
} 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}/proposals/${id}/reject`, { reason: reason || undefined })
|
||
fetchProposal()
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.detail || 'Reject failed')
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const openEditModal = () => {
|
||
if (!proposal) return
|
||
setEditTitle(proposal.title)
|
||
setEditDescription(proposal.description || '')
|
||
setError('')
|
||
setShowEdit(true)
|
||
}
|
||
|
||
const handleEdit = async () => {
|
||
if (!projectId) return
|
||
setEditLoading(true)
|
||
setError('')
|
||
try {
|
||
await api.patch(`/projects/${projectId}/proposals/${id}`, {
|
||
title: editTitle,
|
||
description: editDescription,
|
||
})
|
||
setShowEdit(false)
|
||
fetchProposal()
|
||
} 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}/proposals/${id}/reopen`)
|
||
fetchProposal()
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.detail || 'Reopen failed')
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
if (!proposal) 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>
|
||
💡 {proposal.title}
|
||
{proposal.proposal_code && <span style={{ marginLeft: 8 }}><CopyableCode code={proposal.proposal_code} /></span>}
|
||
</h2>
|
||
<span className={`badge ${statusBadgeClass(proposal.status)}`} style={{ fontSize: '1rem' }}>
|
||
{proposal.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>Proposal Code:</strong> {proposal.proposal_code ? <CopyableCode code={proposal.proposal_code} /> : '—'}</div>
|
||
<div><strong>Status:</strong> {proposal.status}</div>
|
||
<div><strong>Created By:</strong> {proposal.created_by_username || (proposal.created_by_id ? `User #${proposal.created_by_id}` : '—')}</div>
|
||
<div><strong>Created:</strong> {dayjs(proposal.created_at).format('YYYY-MM-DD HH:mm')}</div>
|
||
<div><strong>Updated:</strong> {proposal.updated_at ? dayjs(proposal.updated_at).format('YYYY-MM-DD HH:mm') : '—'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<h3>Description</h3>
|
||
<p style={{ whiteSpace: 'pre-wrap' }}>{proposal.description || 'No description'}</p>
|
||
</div>
|
||
|
||
{/* Generated tasks (shown when accepted) */}
|
||
{proposal.status === 'accepted' && proposal.generated_tasks && proposal.generated_tasks.length > 0 && (
|
||
<div className="section">
|
||
<h3>Generated Tasks</h3>
|
||
<div className="milestone-grid">
|
||
{proposal.generated_tasks.map((gt) => (
|
||
<div
|
||
key={gt.task_id}
|
||
className="milestone-card"
|
||
onClick={() => navigate(`/tasks/${gt.task_code || gt.task_id}`)}
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
<div className="milestone-card-header">
|
||
<span className="badge">{gt.task_type}/{gt.task_subtype}</span>
|
||
{gt.task_code && <span className="badge">{gt.task_code}</span>}
|
||
<h3>{gt.title}</h3>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Action buttons */}
|
||
<div className="section" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||
{proposal.status === 'open' && (
|
||
<>
|
||
<button
|
||
className="btn-transition"
|
||
onClick={openEditModal}
|
||
>
|
||
✏️ Edit
|
||
</button>
|
||
<button
|
||
className="btn-primary"
|
||
disabled={actionLoading}
|
||
onClick={() => { loadMilestones(); setShowAccept(true) }}
|
||
>
|
||
✅ Accept
|
||
</button>
|
||
<button
|
||
className="btn-danger"
|
||
disabled={actionLoading}
|
||
onClick={handleReject}
|
||
>
|
||
❌ Reject
|
||
</button>
|
||
</>
|
||
)}
|
||
{proposal.status === 'rejected' && (
|
||
<button
|
||
className="btn-transition"
|
||
disabled={actionLoading}
|
||
onClick={handleReopen}
|
||
>
|
||
🔄 Reopen
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Edit modal */}
|
||
{showEdit && (
|
||
<div className="modal-overlay" onClick={() => setShowEdit(false)}>
|
||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||
<h3>Edit Proposal</h3>
|
||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||
<strong>Title</strong>
|
||
<input
|
||
type="text"
|
||
value={editTitle}
|
||
onChange={(e) => setEditTitle(e.target.value)}
|
||
style={{ width: '100%', marginTop: 4 }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||
<strong>Description</strong>
|
||
<textarea
|
||
value={editDescription}
|
||
onChange={(e) => setEditDescription(e.target.value)}
|
||
rows={6}
|
||
style={{ width: '100%', marginTop: 4 }}
|
||
/>
|
||
</label>
|
||
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||
<button
|
||
className="btn-primary"
|
||
onClick={handleEdit}
|
||
disabled={!editTitle.trim() || editLoading}
|
||
>
|
||
{editLoading ? 'Saving...' : 'Save'}
|
||
</button>
|
||
<button className="btn-back" onClick={() => setShowEdit(false)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Accept modal with milestone selector */}
|
||
{showAccept && (
|
||
<div className="modal-overlay" onClick={() => setShowAccept(false)}>
|
||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||
<h3>Accept Proposal</h3>
|
||
<p>Select an <strong>open</strong> milestone to generate story tasks 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>
|
||
)
|
||
}
|