'use client'; import { useState, useMemo } from 'react'; import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2, ArrowRightLeft, CalendarRange } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; import { ActivityLogPanel } from '@/components/ActivityLogPanel'; import { CommentPanel } from '@/components/comment/CommentPanel'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { FilterSelect } from '@/components/FilterSelect'; import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, canStartDevTask, formatHours, getEstimateHours, getActualHours, needsDevTaskClaim, } from '@/lib/dev-task'; import { needsDelayReason } from '@/lib/dev-task-transitions'; import { calcWorkHours, formatShortTime, isoToLocal, localToISO } from '@/lib/work-hours'; import type { DevTaskStatus } from '@/lib/dev-task'; interface Props { taskId: string; allTaskIds: string[]; onClose: () => void; contextLabel?: string; readOnly?: boolean; } function defaultPlanStartLocal(): string { const d = new Date(); d.setHours(9, 0, 0, 0); return isoToLocal(d.toISOString()); } function defaultPlanEndLocal(): string { const d = new Date(); d.setHours(18, 0, 0, 0); return isoToLocal(d.toISOString()); } function defaultProgressNextStartLocal(): string { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(9, 30, 0, 0); return isoToLocal(d.toISOString()); } export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, readOnly = false }: Props) { const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore(); const addProgressNote = useWorkActivityStore((s) => s.addProgressNote); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); const user = useAuthStore((s) => s.user); const [showTransfer, setShowTransfer] = useState(false); const [transferTo, setTransferTo] = useState(''); const [showDelayInput, setShowDelayInput] = useState(false); const [delayReason, setDelayReason] = useState(''); const [showPlanInput, setShowPlanInput] = useState(false); const [planStartLocal, setPlanStartLocal] = useState(''); const [planEndLocal, setPlanEndLocal] = useState(''); const [progressNote, setProgressNote] = useState(''); const [progressBlocker, setProgressBlocker] = useState(''); const [progressHelperId, setProgressHelperId] = useState(''); const [progressDelayRisk, setProgressDelayRisk] = useState(''); const [progressNextStartLocal, setProgressNextStartLocal] = useState(() => defaultProgressNextStartLocal()); const task = tasks.find((t) => t.id === taskId); if (!task) return null; const category = categories.find((c) => c.id === task.categoryId); const requirement = requirements.find((r) => r.id === task.requirementId); const allTasks = tasks.filter((t) => allTaskIds.includes(t.id)); const predecessors = (task.predecessorIds || []).map((id) => allTasks.find((t) => t.id === id)).filter(Boolean); const nextStatuses = ALLOWED_TRANSITIONS[task.status] || []; const [blockReason, setBlockReason] = useState(task.blockReason || ''); const [showBlockInput, setShowBlockInput] = useState(false); const estimate = useMemo(() => getEstimateHours(task), [task]); const executorEstimate = typeof task.estimateHours === 'number' && task.estimateHours > 0 ? task.estimateHours : undefined; const aiEstimate = typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0 ? task.aiEstimateHours : undefined; const actual = useMemo(() => getActualHours(task), [task]); const overrun = actual > estimate && estimate > 0; const requireDelay = task.status === 'todo' && needsDelayReason(task); const currentUserName = user?.name || ''; const needsClaim = needsDevTaskClaim(task); const startReady = canStartDevTask(task); const visibleNextStatuses = nextStatuses.filter((status) => status !== 'in_progress' || startReady); const planStartISO = localToISO(planStartLocal); const planEndISO = localToISO(planEndLocal); const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO); const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0; const progressNextStartISO = localToISO(progressNextStartLocal); const canRecordProgress = Boolean( (progressNote.trim() || progressBlocker.trim() || progressDelayRisk.trim()) && progressNextStartISO, ); const openPlanInput = () => { if (readOnly) return; setPlanStartLocal(task.expectedStartAt ? isoToLocal(task.expectedStartAt) : defaultPlanStartLocal()); setPlanEndLocal(task.expectedEndAt ? isoToLocal(task.expectedEndAt) : defaultPlanEndLocal()); setShowPlanInput(true); }; const handleSavePlan = () => { if (readOnly) return; const assigneeId = task.assigneeId || currentUserName; if (!assigneeId) { alert('领取前需要先登录或选择负责人'); return; } if (!planStartBeforeEnd || planEstimateHours <= 0 || !planStartISO || !planEndISO) return; updateTask(task.id, { assigneeId, expectedStartAt: planStartISO, expectedEndAt: planEndISO, estimateHours: planEstimateHours, }); setShowPlanInput(false); }; const handleTransition = (to: DevTaskStatus) => { if (readOnly) return; if (to === 'in_progress' && !startReady) { openPlanInput(); return; } if (to === 'in_progress' && requireDelay && !showDelayInput) { setShowDelayInput(true); return; } const opts = to === 'in_progress' && delayReason.trim() ? { delayReason: delayReason.trim() } : undefined; const result = changeStatus(task.id, to, opts); if (!result.ok) { alert(result.message); return; } if (to === 'in_progress') { setShowDelayInput(false); setDelayReason(''); } }; const handleBlock = () => { if (readOnly) return; if (!blockReason.trim()) return; setBlocked(task.id, true, blockReason.trim()); setShowBlockInput(false); }; const handleUnblock = () => { if (readOnly) return; setBlocked(task.id, false); setBlockReason(''); }; const handleProgressNote = () => { if (readOnly) return; const note = progressNote.trim(); const blocker = progressBlocker.trim(); const delayRisk = progressDelayRisk.trim(); if (!note && !blocker && !delayRisk) return; if (!progressNextStartISO) { alert('请填写下次开始时间'); return; } addProgressNote({ actorId: task.assigneeId, sourceType: 'dev_task', sourceId: task.id, title: task.title, note: note || '今日进展已更新', nextStartAt: progressNextStartISO, blocker: blocker || undefined, helperId: progressHelperId || undefined, delayRisk: delayRisk || undefined, }); setProgressNote(''); setProgressBlocker(''); setProgressHelperId(''); setProgressDelayRisk(''); setProgressNextStartLocal(defaultProgressNextStartLocal()); }; return (
e.stopPropagation()}> {contextLabel && (
{contextLabel}
)}
{task.taskNo} {task.title}
{!readOnly && task.status !== 'submitted' && ( )} {!readOnly && }
{showTransfer && !readOnly && (
转交给: setTransferTo(value === 'all' ? '' : value)} options={members.filter((m) => m.name !== task.assigneeId).map((m) => ({ value: m.name, label: m.name }))} allLabel="选择人员" className="flex-1" labelClassName="max-w-[calc(100%-20px)]" />
)}
{requirement && (
关联需求
{requirement.code} {requirement.title}
)}
状态 & 操作
{DEV_TASK_STATUS_LABEL[task.status]} {task.isBlocked && ( 阻塞中 )} {requireDelay && ( 超期未开始 )}
{visibleNextStatuses.length > 0 && !showDelayInput && !readOnly && (
{visibleNextStatuses.map((s) => ( ))}
)} {task.status === 'todo' && !startReady && !showPlanInput && !readOnly && (
{needsClaim ? '领取时必须填写预计开始和预计截止' : '开始开发前需要补齐预计开始和预计截止'}
)} {showPlanInput && !readOnly && (
{needsClaim ? '领取并填写计划' : '填写计划'}
执行预估:{planEstimateHours > 0 ? formatHours(planEstimateHours) : '请选择有效起止时间'}
)} {showDelayInput && !readOnly && (
已超过预计截止时间,请说明延后原因
setDelayReason(e.target.value)} placeholder="延后原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
)}
{task.isBlocked ? (
{task.blockReason}
{!readOnly && }
) : ( showBlockInput && !readOnly ? (
setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleBlock(); }} placeholder="阻塞原因" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
) : ( !readOnly && ) )}
{task.status !== 'todo' && task.status !== 'submitted' && !readOnly && (
今日进展