Compare commits
4 Commits
a4b4ffcb88
...
2897172213
| Author | SHA1 | Date | |
|---|---|---|---|
| 2897172213 | |||
| 638427db65 | |||
| d6a45c3e17 | |||
| faf7842cba |
@@ -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'] },
|
{ value: 'task', label: 'Task', subtypes: ['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: [] },
|
||||||
|
|||||||
@@ -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'] },
|
{ value: 'task', label: 'Task', subtypes: ['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: [] },
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function MilestoneDetailPage() {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||||
const [closeReason, setCloseReason] = useState('')
|
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 }) => {
|
||||||
@@ -55,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])
|
||||||
@@ -113,6 +121,7 @@ export default function MilestoneDetailPage() {
|
|||||||
await api.post(`/projects/${project.id}/milestones/${milestone.id}/actions/${action}`, body ?? {})
|
await api.post(`/projects/${project.id}/milestones/${milestone.id}/actions/${action}`, body ?? {})
|
||||||
fetchMilestone()
|
fetchMilestone()
|
||||||
refreshMilestoneItems()
|
refreshMilestoneItems()
|
||||||
|
fetchPreflight(project.id)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const detail = err?.response?.data?.detail
|
const detail = err?.response?.data?.detail
|
||||||
setActionError(typeof detail === 'string' ? detail : `${action} failed`)
|
setActionError(typeof detail === 'string' ? detail : `${action} failed`)
|
||||||
@@ -164,22 +173,38 @@ export default function MilestoneDetailPage() {
|
|||||||
{!isTerminal && (
|
{!isTerminal && (
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{msStatus === 'open' && (
|
{msStatus === 'open' && (
|
||||||
|
<span title={preflight?.freeze?.allowed === false ? preflight.freeze.reason ?? '' : ''}>
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={actionLoading === 'freeze'}
|
disabled={actionLoading === 'freeze' || preflight?.freeze?.allowed === false}
|
||||||
onClick={handleFreeze}
|
onClick={handleFreeze}
|
||||||
|
style={preflight?.freeze?.allowed === false ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||||
>
|
>
|
||||||
{actionLoading === 'freeze' ? '⏳ Freezing...' : '🧊 Freeze'}
|
{actionLoading === 'freeze' ? '⏳ Freezing...' : '🧊 Freeze'}
|
||||||
</button>
|
</button>
|
||||||
|
{preflight?.freeze?.allowed === false && (
|
||||||
|
<span className="text-dim" style={{ marginLeft: 8, fontSize: '0.85em' }}>
|
||||||
|
⚠ {preflight.freeze.reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{msStatus === 'freeze' && (
|
{msStatus === 'freeze' && (
|
||||||
|
<span title={preflight?.start?.allowed === false ? preflight.start.reason ?? '' : ''}>
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={actionLoading === 'start'}
|
disabled={actionLoading === 'start' || preflight?.start?.allowed === false}
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
|
style={preflight?.start?.allowed === false ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||||
>
|
>
|
||||||
{actionLoading === 'start' ? '⏳ Starting...' : '▶️ Start'}
|
{actionLoading === 'start' ? '⏳ Starting...' : '▶️ Start'}
|
||||||
</button>
|
</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') && (
|
{(msStatus === 'open' || msStatus === 'freeze' || msStatus === 'undergoing') && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -49,14 +49,13 @@ export default function TaskDetailPage() {
|
|||||||
setComments(data)
|
setComments(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doAction = async (actionName: string, newStatus: string, extra?: () => Promise<void>) => {
|
const doAction = async (actionName: string, newStatus: string, body?: Record<string, any>) => {
|
||||||
setActionLoading(actionName)
|
setActionLoading(actionName)
|
||||||
setActionError(null)
|
setActionError(null)
|
||||||
try {
|
try {
|
||||||
if (extra) await extra()
|
await api.post(`/tasks/${id}/transition?new_status=${newStatus}`, body || {})
|
||||||
await api.post(`/tasks/${id}/transition?new_status=${newStatus}`)
|
|
||||||
await refreshTask()
|
await refreshTask()
|
||||||
// refresh comments too (finish adds one)
|
// refresh comments too (finish adds one via backend)
|
||||||
const { data } = await api.get<Comment[]>(`/tasks/${id}/comments`)
|
const { data } = await api.get<Comment[]>(`/tasks/${id}/comments`)
|
||||||
setComments(data)
|
setComments(data)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -70,17 +69,16 @@ export default function TaskDetailPage() {
|
|||||||
const handleStart = () => doAction('start', 'undergoing')
|
const handleStart = () => doAction('start', 'undergoing')
|
||||||
const handleFinishConfirm = async () => {
|
const handleFinishConfirm = async () => {
|
||||||
if (!finishComment.trim()) return
|
if (!finishComment.trim()) return
|
||||||
await doAction('finish', 'completed', async () => {
|
await doAction('finish', 'completed', { comment: finishComment })
|
||||||
await api.post('/comments', { content: finishComment, task_id: task!.id, author_id: user?.id || 1 })
|
|
||||||
})
|
|
||||||
setShowFinishModal(false)
|
setShowFinishModal(false)
|
||||||
setFinishComment('')
|
setFinishComment('')
|
||||||
}
|
}
|
||||||
const handleCloseConfirm = async () => {
|
const handleCloseConfirm = async () => {
|
||||||
|
const body: Record<string, any> = {}
|
||||||
if (closeReason.trim()) {
|
if (closeReason.trim()) {
|
||||||
await api.post('/comments', { content: `[Close reason] ${closeReason}`, task_id: task!.id, author_id: user?.id || 1 }).catch(() => {})
|
body.comment = `[Close reason] ${closeReason}`
|
||||||
}
|
}
|
||||||
await doAction('close', 'closed')
|
await doAction('close', 'closed', body)
|
||||||
setShowCloseModal(false)
|
setShowCloseModal(false)
|
||||||
setCloseReason('')
|
setCloseReason('')
|
||||||
}
|
}
|
||||||
@@ -90,13 +88,27 @@ export default function TaskDetailPage() {
|
|||||||
() => 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>
|
||||||
|
|
||||||
@@ -115,7 +127,7 @@ 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 && !isTerminal && task.status !== 'undergoing' && (
|
{canEditTask && (
|
||||||
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditTask(true)}>Edit Task</button>
|
<button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditTask(true)}>Edit Task</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user