Merge pull request 'feat: milestone/task status UI + propose pages + action buttons' (#8) from feat/milestone-propose-state-machine into main
Reviewed-on: #8
This commit was merged in pull request #8.
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 />} />
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import api from '@/services/api'
|
|||||||
import type { Milestone, Project, Task } from '@/types'
|
import type { Milestone, Project, Task } from '@/types'
|
||||||
|
|
||||||
const TASK_TYPES = [
|
const TASK_TYPES = [
|
||||||
{ value: 'story', label: 'Story', subtypes: ['feature', 'improvement', 'refactor'] },
|
{ value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from propose accept
|
||||||
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
|
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
|
||||||
{ value: 'task', label: 'Task', subtypes: ['defect'] },
|
// P7.1: 'task' type removed — defect subtype migrated to issue/defect
|
||||||
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
|
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
|
||||||
{ value: 'maintenance', label: 'Maintenance', subtypes: ['deploy', 'release'] },
|
{ value: 'maintenance', label: 'Maintenance', subtypes: ['deploy'] }, // P9.6: 'release' removed — controlled via milestone flow
|
||||||
{ value: 'research', label: 'Research', subtypes: [] },
|
{ value: 'research', label: 'Research', subtypes: [] },
|
||||||
{ value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] },
|
{ value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] },
|
||||||
{ value: 'resolution', label: 'Resolution', subtypes: [] },
|
{ value: 'resolution', label: 'Resolution', subtypes: [] },
|
||||||
@@ -42,7 +42,7 @@ const makeInitialForm = (projectId = 0, milestoneId = 0): FormState => ({
|
|||||||
description: '',
|
description: '',
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
milestone_id: milestoneId,
|
milestone_id: milestoneId,
|
||||||
task_type: 'task',
|
task_type: 'issue', // P7.1: default changed from 'task' to 'issue'
|
||||||
task_subtype: '',
|
task_subtype: '',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
tags: '',
|
tags: '',
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ export default function MilestoneFormModal({ isOpen, onClose, onSaved, milestone
|
|||||||
Status
|
Status
|
||||||
<select value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
<select value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="freeze">Freeze</option>
|
||||||
<option value="deferred">Deferred</option>
|
<option value="undergoing">Undergoing</option>
|
||||||
<option value="progressing">Progressing</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="closed">Closed</option>
|
<option value="closed">Closed</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -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' }] : []),
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ tr.clickable:hover { background: var(--bg-hover); }
|
|||||||
.status-resolved { background: #10b981; }
|
.status-resolved { background: #10b981; }
|
||||||
.status-closed { background: #6b7280; }
|
.status-closed { background: #6b7280; }
|
||||||
.status-blocked { background: #ef4444; }
|
.status-blocked { background: #ef4444; }
|
||||||
|
.status-freeze { background: #8b5cf6; }
|
||||||
|
.status-undergoing { background: #f59e0b; }
|
||||||
|
.status-completed { background: #10b981; }
|
||||||
.priority-low { background: #6b7280; }
|
.priority-low { background: #6b7280; }
|
||||||
.priority-medium { background: #3b82f6; }
|
.priority-medium { background: #3b82f6; }
|
||||||
.priority-high { background: #f59e0b; }
|
.priority-high { background: #f59e0b; }
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import api from '@/services/api'
|
|||||||
import type { Project, Milestone } from '@/types'
|
import type { Project, Milestone } from '@/types'
|
||||||
|
|
||||||
const TASK_TYPES = [
|
const TASK_TYPES = [
|
||||||
{ value: 'story', label: 'Story', subtypes: ['feature', 'improvement', 'refactor'] },
|
{ value: 'story', label: 'Story', subtypes: ['improvement', 'refactor'] }, // P9.6: 'feature' removed — must come from propose accept
|
||||||
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
|
{ value: 'issue', label: 'Issue', subtypes: ['infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'] },
|
||||||
{ value: 'task', label: 'Task', subtypes: ['defect'] },
|
// P7.1: 'task' type removed — defect subtype migrated to issue/defect
|
||||||
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
|
{ value: 'test', label: 'Test', subtypes: ['regression', 'security', 'smoke', 'stress'] },
|
||||||
{ value: 'maintenance', label: 'Maintenance', subtypes: ['deploy', 'release'] },
|
{ value: 'maintenance', label: 'Maintenance', subtypes: ['deploy'] }, // P9.6: 'release' removed — controlled via milestone flow
|
||||||
{ value: 'research', label: 'Research', subtypes: [] },
|
{ value: 'research', label: 'Research', subtypes: [] },
|
||||||
{ value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] },
|
{ value: 'review', label: 'Review', subtypes: ['code_review', 'decision_review', 'function_review'] },
|
||||||
{ value: 'resolution', label: 'Resolution', subtypes: [] },
|
{ value: 'resolution', label: 'Resolution', subtypes: [] },
|
||||||
@@ -19,7 +19,7 @@ export default function CreateTaskPage() {
|
|||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
const [milestones, setMilestones] = useState<Milestone[]>([])
|
const [milestones, setMilestones] = useState<Milestone[]>([])
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', description: '', project_id: 0, milestone_id: 0, task_type: 'task',
|
title: '', description: '', project_id: 0, milestone_id: 0, task_type: 'issue', // P7.1: default changed from 'task' to 'issue'
|
||||||
task_subtype: '', priority: 'medium', tags: '', reporter_id: 1,
|
task_subtype: '', priority: 'medium', tags: '', reporter_id: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export default function MilestoneDetailPage() {
|
|||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [newDesc, setNewDesc] = useState('')
|
const [newDesc, setNewDesc] = useState('')
|
||||||
const [projectCode, setProjectCode] = useState('')
|
const [projectCode, setProjectCode] = useState('')
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||||
|
const [closeReason, setCloseReason] = useState('')
|
||||||
|
const [preflight, setPreflight] = useState<{ freeze?: { allowed: boolean; reason: string | null }; start?: { allowed: boolean; reason: string | null } } | null>(null)
|
||||||
|
|
||||||
const fetchMilestone = () => {
|
const fetchMilestone = () => {
|
||||||
api.get<Milestone>(`/milestones/${id}`).then(({ data }) => {
|
api.get<Milestone>(`/milestones/${id}`).then(({ data }) => {
|
||||||
@@ -51,11 +56,18 @@ export default function MilestoneDetailPage() {
|
|||||||
setProjectCode(proj.project_code || '')
|
setProjectCode(proj.project_code || '')
|
||||||
})
|
})
|
||||||
api.get<ProjectMember[]>(`/projects/${data.project_id}/members`).then(({ data }) => setMembers(data)).catch(() => {})
|
api.get<ProjectMember[]>(`/projects/${data.project_id}/members`).then(({ data }) => setMembers(data)).catch(() => {})
|
||||||
|
fetchPreflight(data.project_id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
api.get<MilestoneProgress>(`/milestones/${id}/progress`).then(({ data }) => setProgress(data)).catch(() => {})
|
api.get<MilestoneProgress>(`/milestones/${id}/progress`).then(({ data }) => setProgress(data)).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchPreflight = (projectId: number) => {
|
||||||
|
api.get(`/projects/${projectId}/milestones/${id}/actions/preflight`)
|
||||||
|
.then(({ data }) => setPreflight(data))
|
||||||
|
.catch(() => setPreflight(null))
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMilestone()
|
fetchMilestone()
|
||||||
}, [id])
|
}, [id])
|
||||||
@@ -96,7 +108,35 @@ export default function MilestoneDetailPage() {
|
|||||||
currentMemberRole === 'admin'
|
currentMemberRole === 'admin'
|
||||||
))
|
))
|
||||||
|
|
||||||
const isProgressing = milestone?.status === 'progressing'
|
const msStatus = milestone?.status
|
||||||
|
const isUndergoing = msStatus === 'undergoing'
|
||||||
|
const isTerminal = msStatus === 'completed' || msStatus === 'closed'
|
||||||
|
|
||||||
|
// --- Milestone action handlers (P8.2) ---
|
||||||
|
const performAction = async (action: string, body?: Record<string, unknown>) => {
|
||||||
|
if (!milestone || !project) return
|
||||||
|
setActionLoading(action)
|
||||||
|
setActionError(null)
|
||||||
|
try {
|
||||||
|
await api.post(`/projects/${project.id}/milestones/${milestone.id}/actions/${action}`, body ?? {})
|
||||||
|
fetchMilestone()
|
||||||
|
refreshMilestoneItems()
|
||||||
|
fetchPreflight(project.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err?.response?.data?.detail
|
||||||
|
setActionError(typeof detail === 'string' ? detail : `${action} failed`)
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFreeze = () => performAction('freeze')
|
||||||
|
const handleStart = () => performAction('start')
|
||||||
|
const handleClose = () => {
|
||||||
|
performAction('close', closeReason ? { reason: closeReason } : {})
|
||||||
|
setShowCloseConfirm(false)
|
||||||
|
setCloseReason('')
|
||||||
|
}
|
||||||
|
|
||||||
if (!milestone) return <div className="loading">Loading...</div>
|
if (!milestone) return <div className="loading">Loading...</div>
|
||||||
|
|
||||||
@@ -117,11 +157,89 @@ export default function MilestoneDetailPage() {
|
|||||||
<div className="task-header">
|
<div className="task-header">
|
||||||
<h2>🏁 {milestone.title}</h2>
|
<h2>🏁 {milestone.title}</h2>
|
||||||
<div className="task-meta">
|
<div className="task-meta">
|
||||||
<span className={`badge status-${milestone.status === 'progressing' ? 'in_progress' : milestone.status}`}>{milestone.status}</span>
|
<span className={`badge status-${milestone.status}`}>{milestone.status}</span>
|
||||||
{milestone.due_date && <span className="text-dim">Due {dayjs(milestone.due_date).format('YYYY-MM-DD')}</span>}
|
{milestone.due_date && <span className="text-dim">Due {dayjs(milestone.due_date).format('YYYY-MM-DD')}</span>}
|
||||||
{milestone.planned_release_date && <span className="text-dim">Planned Release: {dayjs(milestone.planned_release_date).format('YYYY-MM-DD')}</span>}
|
{milestone.planned_release_date && <span className="text-dim">Planned Release: {dayjs(milestone.planned_release_date).format('YYYY-MM-DD')}</span>}
|
||||||
|
{milestone.started_at && <span className="text-dim">Started: {dayjs(milestone.started_at).format('YYYY-MM-DD HH:mm')}</span>}
|
||||||
</div>
|
</div>
|
||||||
{canEditMilestone && <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditMilestone(true)}>Edit Milestone</button>}
|
{canEditMilestone && msStatus === 'open' && <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditMilestone(true)}>Edit Milestone</button>}
|
||||||
|
{canEditMilestone && (msStatus === 'freeze' || msStatus === 'undergoing') && (
|
||||||
|
<span className="text-dim" style={{ marginTop: 8, display: 'inline-block' }}>
|
||||||
|
⚠ Milestone is {msStatus} — scope fields are locked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Milestone status action buttons (P8.2) */}
|
||||||
|
{!isTerminal && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{msStatus === 'open' && (
|
||||||
|
<span title={preflight?.freeze?.allowed === false ? preflight.freeze.reason ?? '' : ''}>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={actionLoading === 'freeze' || preflight?.freeze?.allowed === false}
|
||||||
|
onClick={handleFreeze}
|
||||||
|
style={preflight?.freeze?.allowed === false ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||||
|
>
|
||||||
|
{actionLoading === 'freeze' ? '⏳ Freezing...' : '🧊 Freeze'}
|
||||||
|
</button>
|
||||||
|
{preflight?.freeze?.allowed === false && (
|
||||||
|
<span className="text-dim" style={{ marginLeft: 8, fontSize: '0.85em' }}>
|
||||||
|
⚠ {preflight.freeze.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{msStatus === 'freeze' && (
|
||||||
|
<span title={preflight?.start?.allowed === false ? preflight.start.reason ?? '' : ''}>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={actionLoading === 'start' || preflight?.start?.allowed === false}
|
||||||
|
onClick={handleStart}
|
||||||
|
style={preflight?.start?.allowed === false ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||||
|
>
|
||||||
|
{actionLoading === 'start' ? '⏳ Starting...' : '▶️ Start'}
|
||||||
|
</button>
|
||||||
|
{preflight?.start?.allowed === false && (
|
||||||
|
<span className="text-dim" style={{ marginLeft: 8, fontSize: '0.85em' }}>
|
||||||
|
⚠ {preflight.start.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(msStatus === 'open' || msStatus === 'freeze' || msStatus === 'undergoing') && (
|
||||||
|
<>
|
||||||
|
{!showCloseConfirm ? (
|
||||||
|
<button
|
||||||
|
className="btn-transition"
|
||||||
|
style={{ color: 'var(--color-danger, #e74c3c)' }}
|
||||||
|
onClick={() => setShowCloseConfirm(true)}
|
||||||
|
>
|
||||||
|
✖ Close
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 12px' }}>
|
||||||
|
<input
|
||||||
|
placeholder="Reason (optional)"
|
||||||
|
value={closeReason}
|
||||||
|
onChange={(e) => setCloseReason(e.target.value)}
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ backgroundColor: 'var(--color-danger, #e74c3c)' }}
|
||||||
|
disabled={actionLoading === 'close'}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
{actionLoading === 'close' ? 'Closing...' : 'Confirm Close'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-back" onClick={() => { setShowCloseConfirm(false); setCloseReason('') }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actionError && <p style={{ color: 'var(--color-danger, #e74c3c)', marginTop: 4 }}>⚠️ {actionError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{milestone.description && (
|
{milestone.description && (
|
||||||
@@ -154,14 +272,14 @@ export default function MilestoneDetailPage() {
|
|||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
{!isProgressing && canEditMilestone && (
|
{!isTerminal && !isUndergoing && canEditMilestone && (
|
||||||
<>
|
<>
|
||||||
<button className="btn-primary" onClick={() => { setActiveTab('tasks'); setShowCreateTask(true) }}>+ Create Task</button>
|
<button className="btn-primary" onClick={() => { setActiveTab('tasks'); setShowCreateTask(true) }}>+ Create Task</button>
|
||||||
<button className="btn-primary" onClick={() => { setActiveTab('supports'); setShowCreateSupport(true) }}>+ Create Support</button>
|
<button className="btn-primary" onClick={() => { setActiveTab('supports'); setShowCreateSupport(true) }}>+ Create Support</button>
|
||||||
<button className="btn-primary" onClick={() => { setActiveTab('meetings'); setShowCreateMeeting(true) }}>+ Schedule Meeting</button>
|
<button className="btn-primary" onClick={() => { setActiveTab('meetings'); setShowCreateMeeting(true) }}>+ Schedule Meeting</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isProgressing && <span className="text-dim">Milestone is in progress - cannot add new items</span>}
|
{(isUndergoing || isTerminal) && <span className="text-dim">{isTerminal ? `Milestone is ${msStatus}` : 'Milestone is undergoing'} — cannot add new items</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MilestoneFormModal
|
<MilestoneFormModal
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function MilestonesPage() {
|
|||||||
{milestones.map((ms) => (
|
{milestones.map((ms) => (
|
||||||
<div key={ms.id} className="milestone-card" onClick={() => navigate(`/milestones/${ms.id}`)}>
|
<div key={ms.id} className="milestone-card" onClick={() => navigate(`/milestones/${ms.id}`)}>
|
||||||
<div className="milestone-card-header">
|
<div className="milestone-card-header">
|
||||||
<span className={`badge status-${ms.status === 'progressing' ? 'in_progress' : ms.status}`}>{ms.status}</span>
|
<span className={`badge status-${ms.status}`}>{ms.status}</span>
|
||||||
<h3>{ms.title}</h3>
|
<h3>{ms.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="project-desc">{ms.description || 'No description'}</p>
|
<p className="project-desc">{ms.description || 'No description'}</p>
|
||||||
|
|||||||
265
src/pages/ProposeDetailPage.tsx
Normal file
265
src/pages/ProposeDetailPage.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
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('')
|
||||||
|
|
||||||
|
// Edit state (P10.7)
|
||||||
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
const [editTitle, setEditTitle] = useState('')
|
||||||
|
const [editDescription, setEditDescription] = useState('')
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
|
||||||
|
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 openEditModal = () => {
|
||||||
|
if (!propose) return
|
||||||
|
setEditTitle(propose.title)
|
||||||
|
setEditDescription(propose.description || '')
|
||||||
|
setError('')
|
||||||
|
setShowEdit(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
setEditLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.patch(`/projects/${projectId}/proposes/${id}`, {
|
||||||
|
title: editTitle,
|
||||||
|
description: editDescription,
|
||||||
|
})
|
||||||
|
setShowEdit(false)
|
||||||
|
fetchPropose()
|
||||||
|
} 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}/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-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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Edit modal (P10.7 — only reachable when open) */}
|
||||||
|
{showEdit && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowEdit(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Edit Propose</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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ export default function TaskDetailPage() {
|
|||||||
const [comments, setComments] = useState<Comment[]>([])
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
const [newComment, setNewComment] = useState('')
|
const [newComment, setNewComment] = useState('')
|
||||||
const [showEditTask, setShowEditTask] = useState(false)
|
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 refreshTask = async () => {
|
||||||
const { data } = await api.get<Task>(`/tasks/${id}`)
|
const { data } = await api.get<Task>(`/tasks/${id}`)
|
||||||
@@ -43,31 +49,71 @@ export default function TaskDetailPage() {
|
|||||||
setComments(data)
|
setComments(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const transition = async (newStatus: string) => {
|
const doAction = async (actionName: string, newStatus: string, body?: Record<string, any>) => {
|
||||||
await api.post(`/tasks/${id}/transition?new_status=${newStatus}`)
|
setActionLoading(actionName)
|
||||||
await refreshTask()
|
setActionError(null)
|
||||||
|
try {
|
||||||
|
await api.post(`/tasks/${id}/transition?new_status=${newStatus}`, body || {})
|
||||||
|
await refreshTask()
|
||||||
|
// refresh comments too (finish adds one via backend)
|
||||||
|
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', { comment: finishComment })
|
||||||
|
setShowFinishModal(false)
|
||||||
|
setFinishComment('')
|
||||||
|
}
|
||||||
|
const handleCloseConfirm = async () => {
|
||||||
|
const body: Record<string, any> = {}
|
||||||
|
if (closeReason.trim()) {
|
||||||
|
body.comment = `[Close reason] ${closeReason}`
|
||||||
|
}
|
||||||
|
await doAction('close', 'closed', body)
|
||||||
|
setShowCloseModal(false)
|
||||||
|
setCloseReason('')
|
||||||
|
}
|
||||||
|
const handleReopen = () => doAction('reopen', 'open')
|
||||||
|
|
||||||
const currentMemberRole = useMemo(
|
const currentMemberRole = useMemo(
|
||||||
() => members.find((m) => m.user_id === user?.id)?.role,
|
() => members.find((m) => m.user_id === user?.id)?.role,
|
||||||
[members, user?.id]
|
[members, user?.id]
|
||||||
)
|
)
|
||||||
const canEditTask = Boolean(task && project && user && (
|
const isAdmin = Boolean(user && (
|
||||||
user.is_admin ||
|
user.is_admin ||
|
||||||
user.id === project.owner_id ||
|
(project && user.id === project.owner_id) ||
|
||||||
user.id === task.created_by_id ||
|
|
||||||
user.id === milestone?.created_by_id ||
|
|
||||||
currentMemberRole === 'admin'
|
currentMemberRole === 'admin'
|
||||||
))
|
))
|
||||||
|
// P5.7/P9.3: assignee-aware edit permission
|
||||||
|
const canEditTask = Boolean(task && project && user && (() => {
|
||||||
|
const st = task.status
|
||||||
|
// undergoing/completed/closed: no body edits
|
||||||
|
if (st === 'undergoing' || st === 'completed' || st === 'closed') return false
|
||||||
|
// open + assignee set: only assignee or admin
|
||||||
|
if (st === 'open' && task.assignee_id != null) {
|
||||||
|
return user.id === task.assignee_id || isAdmin
|
||||||
|
}
|
||||||
|
// open + no assignee, or pending: general permission
|
||||||
|
return (
|
||||||
|
isAdmin ||
|
||||||
|
user.id === task.created_by_id ||
|
||||||
|
user.id === milestone?.created_by_id
|
||||||
|
)
|
||||||
|
})())
|
||||||
|
|
||||||
if (!task) return <div className="loading">Loading...</div>
|
if (!task) return <div className="loading">Loading...</div>
|
||||||
|
|
||||||
const statusActions: Record<string, string[]> = {
|
// Determine which action buttons to show based on status (P9.2 / P11.8)
|
||||||
open: ['progressing', 'closed'],
|
const isTerminal = task.status === 'completed' || task.status === 'closed'
|
||||||
pending: ['progressing', 'closed'],
|
|
||||||
progressing: ['pending', 'closed'],
|
|
||||||
closed: ['open'],
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task-detail">
|
<div className="task-detail">
|
||||||
@@ -81,7 +127,9 @@ export default function TaskDetailPage() {
|
|||||||
<span className="badge">{task.task_type}</span>{task.task_subtype && <span className="badge">{task.task_subtype}</span>}
|
<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>}
|
{task.tags && <span className="tags">{task.tags}</span>}
|
||||||
</div>
|
</div>
|
||||||
{canEditTask && <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditTask(true)}>Edit Task</button>}
|
{canEditTask && (
|
||||||
|
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditTask(true)}>Edit Task</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateTaskModal
|
<CreateTaskModal
|
||||||
@@ -108,14 +156,107 @@ export default function TaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<h3>Status changes</h3>
|
<h3>Actions</h3>
|
||||||
<div className="actions">
|
{actionError && <div className="error-message" style={{ color: '#dc2626', marginBottom: 8 }}>{actionError}</div>}
|
||||||
{(statusActions[task.status] || []).map((s) => (
|
<div className="actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<button key={s} className="btn-transition" onClick={() => transition(s)}>{s}</button>
|
{/* 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>
|
||||||
</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">
|
<div className="section">
|
||||||
<h3>Comments ({comments.length})</h3>
|
<h3>Comments ({comments.length})</h3>
|
||||||
{comments.map((c) => (
|
{comments.map((c) => (
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export interface Task {
|
|||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
task_type: 'issue' | 'maintenance' | 'research' | 'review' | 'story' | 'test' | 'resolution' | 'task'
|
task_type: 'issue' | 'maintenance' | 'research' | 'review' | 'story' | 'test' | 'resolution' // P7.1: 'task' removed
|
||||||
task_subtype: string | null
|
task_subtype: string | null
|
||||||
status: 'open' | 'pending' | 'progressing' | 'closed'
|
status: 'open' | 'pending' | 'undergoing' | 'completed' | 'closed'
|
||||||
priority: 'low' | 'medium' | 'high' | 'critical'
|
priority: 'low' | 'medium' | 'high' | 'critical'
|
||||||
project_id: number
|
project_id: number
|
||||||
milestone_id: number | null
|
milestone_id: number | null
|
||||||
@@ -64,7 +64,8 @@ export interface Milestone {
|
|||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
status: 'open' | 'pending' | 'deferred' | 'progressing' | 'closed'
|
status: 'open' | 'freeze' | 'undergoing' | 'completed' | 'closed'
|
||||||
|
started_at: string | null
|
||||||
project_id: number
|
project_id: number
|
||||||
created_by_id: number | null
|
created_by_id: number | null
|
||||||
due_date: string | null
|
due_date: string | null
|
||||||
@@ -119,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