Files
ftb-project-management/apps/web/components/dev-task/DevTaskDetailDrawer.tsx
Script Generator 75b0d12fa0 refactor(dev-task): 移除工时记录,改为由开始/完成日期自动计算实际工时
- 状态流转到"开发中"时自动记录 startDate
- 实际工时 = startDate → completedAt 的工作日 × 8h
- 详情抽屉移除工时记录面板,增加"开发开始日期"展示
- 列表行工时展示同步改为日期计算

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 09:31:30 +08:00

208 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState } from 'react';
import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { CategoryChip } from './CategoryChip';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours, calcActualHoursByDates } from '@/lib/dev-task';
import type { DevTaskStatus } from '@/lib/dev-task';
interface Props {
taskId: string;
allTaskIds: string[];
onClose: () => void;
}
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose }: Props) {
const { tasks, changeStatus, setBlocked } = useDevTaskStore();
const { categories } = useTaskCategoryStore();
const { requirements } = useRequirementStore();
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 handleTransition = (to: DevTaskStatus) => {
const result = changeStatus(task.id, to);
if (!result.ok) alert(result.message);
};
const handleBlock = () => {
if (!blockReason.trim()) return;
setBlocked(task.id, true, blockReason.trim());
setShowBlockInput(false);
};
const handleUnblock = () => {
setBlocked(task.id, false);
setBlockReason('');
};
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-[var(--shadow-lg)] flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* 顶栏 */}
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
<div className="flex items-center gap-2">
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{task.title}</span>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
{/* 滚动区域 */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{/* 卡片1关联需求 */}
{requirement && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5"></div>
<div className="flex items-center gap-2">
<Link2 className="h-3.5 w-3.5 text-[var(--accent)]" />
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
<span className="text-[13px] text-[var(--ink)]">{requirement.title}</span>
</div>
</div>
)}
{/* 卡片2状态 & 操作 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"> & </div>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center rounded-lg px-3 py-1.5 text-[13px] font-semibold ${DEV_TASK_STATUS_COLOR[task.status]}`}>
{DEV_TASK_STATUS_LABEL[task.status]}
</span>
{task.isBlocked && (
<span className="inline-flex items-center gap-1 text-[11px] text-red-600 bg-red-50 border border-red-200 px-2 py-1 rounded-lg">
<AlertTriangle className="h-3 w-3" />
</span>
)}
</div>
{/* 流转按钮 */}
{nextStatuses.length > 0 && (
<div className="flex items-center gap-2 pt-1">
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
{nextStatuses.map((s) => (
<button key={s} onClick={() => handleTransition(s)} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors">
{DEV_TASK_STATUS_LABEL[s]}
</button>
))}
</div>
)}
{/* 阻塞管理 */}
<div className="pt-2 border-t border-[var(--line)]">
{task.isBlocked ? (
<div className="space-y-2">
<div className="flex items-start gap-1.5 text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{task.blockReason}</span>
</div>
<button onClick={handleUnblock} className="text-[11px] text-emerald-600 font-medium hover:underline"></button>
</div>
) : (
showBlockInput ? (
<div className="flex gap-2">
<input value={blockReason} onChange={(e) => 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 />
<button onClick={handleBlock} disabled={!blockReason.trim()} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowBlockInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
) : (
<button onClick={() => setShowBlockInput(true)} className="text-[11px] text-red-500 font-medium hover:underline"></button>
)
)}
</div>
</div>
{/* 卡片3基本信息 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3"></div>
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
<div className="flex items-center gap-2">
<Tag className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<CategoryChip category={category} />
</div>
<div className="flex items-center gap-2">
<User className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{task.assigneeId}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{task.priority}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{formatHours(task.estimateHours)}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{task.startDate ? formatHours(calcActualHoursByDates(task.startDate, task.completedAt)) : '-'}</span>
</div>
{task.startDate && (
<div className="flex items-center gap-2">
<Play className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{task.startDate}</span>
</div>
)}
{task.dueDate && (
<div className="flex items-center gap-2">
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{task.dueDate}</span>
</div>
)}
{task.completedAt && (
<div className="flex items-center gap-2">
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-emerald-600 font-medium">{task.completedAt}</span>
</div>
)}
</div>
{task.description && (
<div className="mt-3 pt-3 border-t border-[var(--line)]">
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed">{task.description}</p>
</div>
)}
</div>
{/* 卡片4前置任务 */}
{predecessors.length > 0 && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2"></div>
<div className="space-y-2">
{predecessors.map((p: any) => (
<div key={p.id} className="flex items-center gap-2 text-[12px] px-2.5 py-1.5 rounded-lg bg-[var(--bg-subtle)]">
<Link2 className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="font-mono text-[var(--ink-muted)]">{p.taskNo}</span>
<span className="text-[var(--ink)] flex-1 truncate">{p.title}</span>
<StatusBadge status={p.status} />
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}