Compare commits

...

3 Commits

Author SHA1 Message Date
zhi
e6b91e9558 P9.2+P9.4: Task action buttons (open/start/finish/close/reopen) with finish-comment modal and close-reason modal 2026-03-17 07:07:04 +00:00
zhi
18703d98f8 feat(P8.1-P8.2): milestone status action buttons + badge styles + started_at display
- Add freeze/start/close action buttons on MilestoneDetailPage
- Freeze: visible in open status, calls POST .../actions/freeze
- Start: visible in freeze status, calls POST .../actions/start
- Close: visible in open/freeze/undergoing, with reason input + confirmation
- Display started_at in milestone meta when present
- Hide edit button and create-item buttons in terminal states
- Add CSS badge styles for freeze (purple), undergoing (amber), completed (green)
- All actions show loading state and error feedback
2026-03-17 06:05:09 +00:00
zhi
35e7d3a141 feat(P10): add Propose type, list page, detail page with accept/reject/reopen + sidebar link 2026-03-17 05:03:49 +00:00
8 changed files with 557 additions and 20 deletions

View File

@@ -14,6 +14,8 @@ import MilestoneDetailPage from '@/pages/MilestoneDetailPage'
import NotificationsPage from '@/pages/NotificationsPage'
import RoleEditorPage from '@/pages/RoleEditorPage'
import MonitorPage from '@/pages/MonitorPage'
import ProposesPage from '@/pages/ProposesPage'
import ProposeDetailPage from '@/pages/ProposeDetailPage'
import axios from 'axios'
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="/milestones" element={<MilestonesPage />} />
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
<Route path="/proposes" element={<ProposesPage />} />
<Route path="/proposes/:id" element={<ProposeDetailPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/roles" element={<RoleEditorPage />} />
<Route path="/monitor" element={<MonitorPage />} />

View File

@@ -32,6 +32,7 @@ export default function Sidebar({ user, onLogout }: Props) {
const links = user ? [
{ to: '/', icon: '📊', label: 'Dashboard' },
{ to: '/projects', icon: '📁', label: 'Projects' },
{ to: '/proposes', icon: '💡', label: 'Proposes' },
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
{ to: '/monitor', icon: '📡', label: 'Monitor' },
...(user.is_admin ? [{ to: '/roles', icon: '🔐', label: 'Roles' }] : []),

View File

@@ -72,6 +72,9 @@ tr.clickable:hover { background: var(--bg-hover); }
.status-resolved { background: #10b981; }
.status-closed { background: #6b7280; }
.status-blocked { background: #ef4444; }
.status-freeze { background: #8b5cf6; }
.status-undergoing { background: #f59e0b; }
.status-completed { background: #10b981; }
.priority-low { background: #6b7280; }
.priority-medium { background: #3b82f6; }
.priority-high { background: #f59e0b; }

View File

@@ -41,6 +41,10 @@ export default function MilestoneDetailPage() {
const [newTitle, setNewTitle] = useState('')
const [newDesc, setNewDesc] = 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 fetchMilestone = () => {
api.get<Milestone>(`/milestones/${id}`).then(({ data }) => {
@@ -96,7 +100,34 @@ export default function MilestoneDetailPage() {
currentMemberRole === 'admin'
))
const isUndergoing = milestone?.status === 'undergoing'
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()
} 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>
@@ -120,8 +151,65 @@ export default function MilestoneDetailPage() {
<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.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>
{canEditMilestone && <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditMilestone(true)}>Edit Milestone</button>}
{canEditMilestone && !isTerminal && <button className="btn-transition" style={{ marginTop: 8 }} onClick={() => setShowEditMilestone(true)}>Edit Milestone</button>}
{/* Milestone status action buttons (P8.2) */}
{!isTerminal && (
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{msStatus === 'open' && (
<button
className="btn-primary"
disabled={actionLoading === 'freeze'}
onClick={handleFreeze}
>
{actionLoading === 'freeze' ? '⏳ Freezing...' : '🧊 Freeze'}
</button>
)}
{msStatus === 'freeze' && (
<button
className="btn-primary"
disabled={actionLoading === 'start'}
onClick={handleStart}
>
{actionLoading === 'start' ? '⏳ Starting...' : '▶️ Start'}
</button>
)}
{(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>
{milestone.description && (
@@ -154,14 +242,14 @@ export default function MilestoneDetailPage() {
<div className="section">
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{!isUndergoing && canEditMilestone && (
{!isTerminal && !isUndergoing && canEditMilestone && (
<>
<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('meetings'); setShowCreateMeeting(true) }}>+ Schedule Meeting</button>
</>
)}
{isUndergoing && <span className="text-dim">Milestone is undergoing - cannot add new items</span>}
{(isUndergoing || isTerminal) && <span className="text-dim">{isTerminal ? `Milestone is ${msStatus}` : 'Milestone is undergoing'} cannot add new items</span>}
</div>
<MilestoneFormModal

View 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>
)
}

110
src/pages/ProposesPage.tsx Normal file
View 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>
)
}

View File

@@ -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,11 +49,43 @@ export default function TaskDetailPage() {
setComments(data)
}
const transition = async (newStatus: string) => {
await api.post(`/tasks/${id}/transition?new_status=${newStatus}`)
await refreshTask()
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,
[members, user?.id]
@@ -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) => (

View File

@@ -120,6 +120,19 @@ export interface DashboardStats {
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 {
access_token: string
token_type: string