Files
ftb-project-management/apps/web/components/dev-task/DevTaskCreateModal.tsx

298 lines
14 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, useMemo, useEffect } from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
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 { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector';
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
import type { Priority } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
interface Props {
versionId: string;
requirementIds: string[];
versionRequirements?: Requirement[];
versionDeadline?: string;
onClose: () => void;
onCreated?: () => void;
}
function defaultExpectedStart(): string {
const d = new Date();
d.setHours(9, 0, 0, 0);
return isoToLocal(d.toISOString());
}
function defaultExpectedEnd(): string {
const d = new Date();
d.setHours(18, 0, 0, 0);
return isoToLocal(d.toISOString());
}
export function DevTaskCreateModal({ versionId, requirementIds, versionRequirements, versionDeadline, onClose, onCreated }: Props) {
const { createTask, tasks } = useDevTaskStore();
const { categories } = useTaskCategoryStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const requirementSource = versionRequirements ?? requirements;
const versionReqs = useMemo(
() => getVersionLinkedRequirementCandidates(requirementSource, requirementIds),
[requirementSource, requirementIds],
);
const versionTasks = useMemo(
() => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)),
[tasks, requirementIds, versionId],
);
const [title, setTitle] = useState('');
const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || '');
const [categoryId, setCategoryId] = useState(categories[0]?.id || '');
const [assigneeId, setAssigneeId] = useState(user?.name || '');
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
const [priorityManuallySet, setPriorityManuallySet] = useState(false);
const [expectedStartLocal, setExpectedStartLocal] = useState(defaultExpectedStart);
const [expectedEndLocal, setExpectedEndLocal] = useState(defaultExpectedEnd);
const [predecessorIds, setPredecessorIds] = useState<string[]>([]);
const [description, setDescription] = useState('');
const [overdueVersionReason, setOverdueVersionReason] = useState('');
const [prototypeNotes, setPrototypeNotes] = useState('');
useEffect(() => {
if (categories[0] && !categoryId) setCategoryId(categories[0].id);
}, [categories, categoryId]);
useEffect(() => {
if (versionReqs.length === 0) {
if (requirementId) setRequirementId('');
return;
}
if (versionReqs.some((r) => r.id === requirementId)) return;
const nextRequirement = versionReqs[0];
setRequirementId(nextRequirement.id);
if (!priorityManuallySet) setPriority(nextRequirement.priority);
}, [versionReqs, requirementId, priorityManuallySet]);
const expectedStartISO = localToISO(expectedStartLocal);
const expectedEndISO = localToISO(expectedEndLocal);
const estimateHours = useMemo(() => {
if (!expectedStartISO || !expectedEndISO) return 0;
return calcWorkHours(expectedStartISO, expectedEndISO);
}, [expectedStartISO, expectedEndISO]);
const selectedReq = versionReqs.find((r) => r.id === requirementId);
const effectivePriority = priorityManuallySet ? priority : (selectedReq?.priority || priority);
const versionDeadlineISO = useMemo(() => {
if (!versionDeadline) return null;
const d = new Date(versionDeadline);
if (isNaN(d.getTime())) return null;
d.setHours(23, 59, 59, 999);
return d.toISOString();
}, [versionDeadline]);
const isOverdueVersion = !!(expectedEndISO && versionDeadlineISO && expectedEndISO > versionDeadlineISO);
const startBeforeEnd = expectedStartISO && expectedEndISO && expectedStartISO < expectedEndISO;
const canSubmit =
title.trim() &&
requirementId &&
categoryId &&
assigneeId &&
!!startBeforeEnd &&
estimateHours > 0 &&
(!isOverdueVersion || overdueVersionReason.trim());
const handleSubmit = () => {
if (!canSubmit) return;
const reqRef = versionReqs.find((r) => r.id === requirementId);
const references = [
...(reqRef ? [{ type: 'requirement' as const, id: reqRef.code, label: `${reqRef.code} ${reqRef.title}` }] : []),
...prototypeNotes.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).map((note) => ({
type: 'prototype_note' as const,
id: note,
label: note,
})),
];
createTask({
versionId,
requirementId,
title: title.trim(),
description: description.trim() || undefined,
categoryId,
assigneeId,
reviewerId: undefined,
priority: effectivePriority,
expectedStartAt: expectedStartISO,
expectedEndAt: expectedEndISO,
estimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
blockReason: undefined,
blockedById: undefined,
predecessorIds: predecessorIds.length > 0 ? predecessorIds : undefined,
riskLevel: undefined,
delayReason: undefined,
overdueVersionReason: isOverdueVersion ? overdueVersionReason.trim() : undefined,
references: references.length > 0 ? references : undefined,
createdBy: user?.name || '系统',
});
onCreated?.();
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-3xl max-h-[92vh] overflow-y-auto rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="例如:排班界面开发" />
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<FilterSelect
value={requirementId}
onChange={(value) => {
setRequirementId(value);
const r = versionReqs.find((x) => x.id === value);
if (r && !priorityManuallySet) setPriority(r.priority);
}}
options={versionReqs.map((r) => ({ value: r.id, label: `${r.code} ${r.title}` }))}
placeholder="选择所属需求"
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<FilterSelect
value={categoryId}
onChange={setCategoryId}
options={categories.map((c) => ({ value: c.id, label: c.name }))}
placeholder="选择任务类型"
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<FilterSelect
value={assigneeId}
onChange={setAssigneeId}
options={members.map((m) => ({ value: m.name, label: m.name }))}
placeholder="选择负责人"
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<WorkDateTimePicker
value={expectedStartLocal}
onChange={setExpectedStartLocal}
placeholder="选择预计开始时间"
defaultHour={9}
className="bg-[var(--bg)]"
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<WorkDateTimePicker
value={expectedEndLocal}
onChange={setExpectedEndLocal}
placeholder="选择预计截止时间"
defaultHour={18}
popoverAlign="right"
className="bg-[var(--bg)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<FilterSelect
value={effectivePriority}
onChange={(value) => { setPriority(value as Priority); setPriorityManuallySet(true); }}
options={(['P0', 'P1', 'P2', 'P3'] as Priority[]).map((p) => ({ value: p, label: p }))}
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<div className="h-9 flex items-center px-3 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] text-[13px] text-[var(--ink-soft)] tabular-nums">
{estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')}
</div>
<p className="text-[10px] text-[var(--ink-muted)] mt-0.5"> 9:0012:00 + 13:0018:00</p>
</div>
</div>
{!startBeforeEnd && expectedStartLocal && expectedEndLocal && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-2 text-[11px] text-amber-700">
</div>
)}
{versionDeadline && (
<p className="text-[10px] text-[var(--ink-muted)]">{versionDeadline}</p>
)}
{isOverdueVersion && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
<AlertTriangle className="h-3.5 w-3.5" />
{versionDeadline}
</div>
<input value={overdueVersionReason} onChange={(e) => setOverdueVersionReason(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" />
</div>
)}
{versionTasks.length > 0 && (
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<div className="space-y-1 max-h-28 overflow-y-auto border border-[var(--line)] rounded-lg p-2">
{versionTasks.map((t) => (
<label key={t.id} className="flex items-center gap-2 text-[12px] text-[var(--ink)]">
<input type="checkbox" checked={predecessorIds.includes(t.id)} onChange={(e) => setPredecessorIds(e.target.checked ? [...predecessorIds, t.id] : predecessorIds.filter((x) => x !== t.id))} />
<span className="text-[var(--ink-muted)] font-mono">{t.taskNo}</span> {t.title}
</label>
))}
</div>
</div>
)}
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="任务描述(可选)" />
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<input value={prototypeNotes} onChange={(e) => setPrototypeNotes(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="如 QY0007, QY0023" />
<p className="mt-1 text-[11px] text-[var(--ink-muted)]"></p>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={handleSubmit} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
);
}