P9.2+P9.4: Task action buttons (open/start/finish/close/reopen) with finish-comment modal and close-reason modal
This commit is contained in:
@@ -17,6 +17,12 @@ export default function TaskDetailPage() {
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [showEditTask, setShowEditTask] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [showFinishModal, setShowFinishModal] = useState(false)
|
||||
const [finishComment, setFinishComment] = useState('')
|
||||
const [showCloseModal, setShowCloseModal] = useState(false)
|
||||
const [closeReason, setCloseReason] = useState('')
|
||||
|
||||
const refreshTask = async () => {
|
||||
const { data } = await api.get<Task>(`/tasks/${id}`)
|
||||
@@ -43,10 +49,42 @@ export default function TaskDetailPage() {
|
||||
setComments(data)
|
||||
}
|
||||
|
||||
const transition = async (newStatus: string) => {
|
||||
const doAction = async (actionName: string, newStatus: string, extra?: () => Promise<void>) => {
|
||||
setActionLoading(actionName)
|
||||
setActionError(null)
|
||||
try {
|
||||
if (extra) await extra()
|
||||
await api.post(`/tasks/${id}/transition?new_status=${newStatus}`)
|
||||
await refreshTask()
|
||||
// refresh comments too (finish adds one)
|
||||
const { data } = await api.get<Comment[]>(`/tasks/${id}/comments`)
|
||||
setComments(data)
|
||||
} catch (err: any) {
|
||||
setActionError(err?.response?.data?.detail || err?.message || 'Action failed')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = () => doAction('open', 'open')
|
||||
const handleStart = () => doAction('start', 'undergoing')
|
||||
const handleFinishConfirm = async () => {
|
||||
if (!finishComment.trim()) return
|
||||
await doAction('finish', 'completed', async () => {
|
||||
await api.post('/comments', { content: finishComment, task_id: task!.id, author_id: user?.id || 1 })
|
||||
})
|
||||
setShowFinishModal(false)
|
||||
setFinishComment('')
|
||||
}
|
||||
const handleCloseConfirm = async () => {
|
||||
if (closeReason.trim()) {
|
||||
await api.post('/comments', { content: `[Close reason] ${closeReason}`, task_id: task!.id, author_id: user?.id || 1 }).catch(() => {})
|
||||
}
|
||||
await doAction('close', 'closed')
|
||||
setShowCloseModal(false)
|
||||
setCloseReason('')
|
||||
}
|
||||
const handleReopen = () => doAction('reopen', 'open')
|
||||
|
||||
const currentMemberRole = useMemo(
|
||||
() => members.find((m) => m.user_id === user?.id)?.role,
|
||||
@@ -62,13 +100,8 @@ export default function TaskDetailPage() {
|
||||
|
||||
if (!task) return <div className="loading">Loading...</div>
|
||||
|
||||
const statusActions: Record<string, string[]> = {
|
||||
open: ['undergoing', 'closed'],
|
||||
pending: ['open', 'closed'],
|
||||
undergoing: ['completed', 'closed'],
|
||||
completed: ['open'],
|
||||
closed: ['open'],
|
||||
}
|
||||
// Determine which action buttons to show based on status (P9.2 / P11.8)
|
||||
const isTerminal = task.status === 'completed' || task.status === 'closed'
|
||||
|
||||
return (
|
||||
<div className="task-detail">
|
||||
@@ -82,7 +115,9 @@ export default function TaskDetailPage() {
|
||||
<span className="badge">{task.task_type}</span>{task.task_subtype && <span className="badge">{task.task_subtype}</span>}
|
||||
{task.tags && <span className="tags">{task.tags}</span>}
|
||||
</div>
|
||||
{canEditTask && <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditTask(true)}>Edit Task</button>}
|
||||
{canEditTask && !isTerminal && task.status !== 'undergoing' && (
|
||||
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditTask(true)}>Edit Task</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateTaskModal
|
||||
@@ -109,14 +144,107 @@ export default function TaskDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>Status changes</h3>
|
||||
<div className="actions">
|
||||
{(statusActions[task.status] || []).map((s) => (
|
||||
<button key={s} className="btn-transition" onClick={() => transition(s)}>{s}</button>
|
||||
))}
|
||||
<h3>Actions</h3>
|
||||
{actionError && <div className="error-message" style={{ color: '#dc2626', marginBottom: 8 }}>{actionError}</div>}
|
||||
<div className="actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{/* pending: Open + Close */}
|
||||
{task.status === 'pending' && (
|
||||
<>
|
||||
<button className="btn-transition" disabled={!!actionLoading} onClick={handleOpen}>
|
||||
{actionLoading === 'open' ? 'Opening…' : '▶ Open'}
|
||||
</button>
|
||||
<button className="btn-transition" style={{ background: '#dc2626', color: '#fff' }} disabled={!!actionLoading} onClick={() => setShowCloseModal(true)}>
|
||||
{actionLoading === 'close' ? 'Closing…' : '✕ Close'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* open: Start + Close */}
|
||||
{task.status === 'open' && (
|
||||
<>
|
||||
<button className="btn-transition" style={{ background: '#f59e0b', color: '#fff' }} disabled={!!actionLoading} onClick={handleStart}>
|
||||
{actionLoading === 'start' ? 'Starting…' : '⏵ Start'}
|
||||
</button>
|
||||
<button className="btn-transition" style={{ background: '#dc2626', color: '#fff' }} disabled={!!actionLoading} onClick={() => setShowCloseModal(true)}>
|
||||
{actionLoading === 'close' ? 'Closing…' : '✕ Close'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* undergoing: Finish + Close */}
|
||||
{task.status === 'undergoing' && (
|
||||
<>
|
||||
<button className="btn-transition" style={{ background: '#16a34a', color: '#fff' }} disabled={!!actionLoading} onClick={() => setShowFinishModal(true)}>
|
||||
{actionLoading === 'finish' ? 'Finishing…' : '✓ Finish'}
|
||||
</button>
|
||||
<button className="btn-transition" style={{ background: '#dc2626', color: '#fff' }} disabled={!!actionLoading} onClick={() => setShowCloseModal(true)}>
|
||||
{actionLoading === 'close' ? 'Closing…' : '✕ Close'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* completed / closed: Reopen */}
|
||||
{(task.status === 'completed' || task.status === 'closed') && (
|
||||
<button className="btn-transition" disabled={!!actionLoading} onClick={handleReopen}>
|
||||
{actionLoading === 'reopen' ? 'Reopening…' : '↺ Reopen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finish modal — requires comment (P9.4) */}
|
||||
{showFinishModal && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div className="modal-content" style={{ background: '#fff', borderRadius: 8, padding: 24, minWidth: 400, maxWidth: 520 }}>
|
||||
<h3>Finish Task</h3>
|
||||
<p style={{ fontSize: 14, color: '#666', marginBottom: 12 }}>Please leave a completion comment before finishing.</p>
|
||||
<textarea
|
||||
value={finishComment}
|
||||
onChange={(e) => setFinishComment(e.target.value)}
|
||||
placeholder="Describe what was done…"
|
||||
rows={4}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn-transition" onClick={() => { setShowFinishModal(false); setFinishComment('') }}>Cancel</button>
|
||||
<button
|
||||
className="btn-transition"
|
||||
style={{ background: '#16a34a', color: '#fff' }}
|
||||
disabled={!finishComment.trim() || !!actionLoading}
|
||||
onClick={handleFinishConfirm}
|
||||
>
|
||||
{actionLoading === 'finish' ? 'Finishing…' : 'Confirm Finish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close modal — optional reason */}
|
||||
{showCloseModal && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div className="modal-content" style={{ background: '#fff', borderRadius: 8, padding: 24, minWidth: 400, maxWidth: 520 }}>
|
||||
<h3>Close Task</h3>
|
||||
<p style={{ fontSize: 14, color: '#666', marginBottom: 12 }}>This will cancel/abandon the task. Optionally provide a reason.</p>
|
||||
<textarea
|
||||
value={closeReason}
|
||||
onChange={(e) => setCloseReason(e.target.value)}
|
||||
placeholder="Reason for closing (optional)…"
|
||||
rows={3}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn-transition" onClick={() => { setShowCloseModal(false); setCloseReason('') }}>Cancel</button>
|
||||
<button
|
||||
className="btn-transition"
|
||||
style={{ background: '#dc2626', color: '#fff' }}
|
||||
disabled={!!actionLoading}
|
||||
onClick={handleCloseConfirm}
|
||||
>
|
||||
{actionLoading === 'close' ? 'Closing…' : 'Confirm Close'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<h3>Comments ({comments.length})</h3>
|
||||
{comments.map((c) => (
|
||||
|
||||
Reference in New Issue
Block a user