import { useState, useCallback, useMemo, useEffect, memo } from "react";
import {
Search,
Loader2,
AlertCircle,
GitBranch,
RefreshCw,
X,
Plus,
Warehouse,
UserPlus,
} from "lucide-react";
import TaskFlowView from "./TaskFlowView";
import {
getTaskTree,
receiveTask,
rejectTask,
transferTask,
} from "../../services/taskApi";
import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
import ImageUploader from "../ui/ImageUploader";
import { extractErrorMessage } from "../../utils/errorMessage";
// ============================================================
// 通用 Modal 容器
// ============================================================
export const Modal = memo(function Modal({
open,
onClose,
title,
children,
widthClass = "max-w-md",
bodyClassName = "",
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
widthClass?: string;
bodyClassName?: string;
}) {
// 🚀 弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [open]);
if (!open) return null;
return (
);
});
// ============================================================
// 确认接收弹窗
// ============================================================
export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
open,
task,
submitting,
onClose,
onConfirm,
}: {
open: boolean;
task: TaskResponse | null;
submitting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
if (!task) return null;
return (
{task.task_name}
当前状态: {getStatusConfig(task.status).label}
{" → "}
进行中 (WIP)
确认接收后,该任务将标记为"进行中",系统将记录接收时间。
);
});
// ============================================================
// 品质驳回弹窗
// ============================================================
export const RejectModal = memo(function RejectModal({
open,
task,
submitting,
onClose,
onSubmit,
}: {
open: boolean;
task: TaskResponse | null;
submitting: boolean;
onClose: () => void;
onSubmit: (reason: string, images: string[]) => void;
}) {
const [reason, setReason] = useState("");
const [images, setImages] = useState([]);
if (!task) return null;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = reason.trim();
if (!trimmed) return;
// 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交
onSubmit(trimmed, images);
setReason("");
setImages([]);
}
function handleClose() {
setReason("");
setImages([]);
onClose();
}
return (
{task.task_name}
⚠ 驳回后将自动生成返工任务,分配给上一道工序负责人
);
});
// ============================================================
// 完工裂变转交弹窗
// ============================================================
export const TransferModal = memo(function TransferModal({
open,
task,
submitting,
onClose,
onSubmit,
}: {
open: boolean;
task: TaskResponse | null;
submitting: boolean;
onClose: () => void;
onSubmit: (
nextTaskName: string,
assignees: string[],
note: string,
finishDirectly: boolean
) => void;
}) {
const [nextTaskName, setNextTaskName] = useState("");
const [assigneeInput, setAssigneeInput] = useState("");
const [assignees, setAssignees] = useState([]);
const [useWarehouse, setUseWarehouse] = useState(false);
// 🏁 直接完结:无下游、不入库。与「转交个人 / 入库」互斥
const [finishDirectly, setFinishDirectly] = useState(false);
const [note, setNote] = useState("");
// 🔄 每次打开都重置:TransferModal 被 memo 后常驻挂载(task 为 null 时只是 return null),
// 状态会跨次打开残留,必须显式清零。
// ⚠️ 必须放在 `if (!task) return null` 之前 —— 否则是条件提前返回后再调 Hook,
// 违反 Rules of Hooks,task 从 null 变非 null 时 Hook 数量错位会直接崩。
useEffect(() => {
if (!open) return;
setNextTaskName("");
setAssigneeInput("");
setAssignees([]);
setUseWarehouse(false);
setFinishDirectly(false);
setNote("");
}, [open]);
if (!task) return null;
function handleAddAssignee() {
const trimmed = assigneeInput.trim();
if (!trimmed) return;
if (assignees.includes(trimmed)) {
setAssigneeInput("");
return;
}
setAssignees((prev) => [...prev, trimmed]);
setAssigneeInput("");
}
function handleRemoveAssignee(id: string) {
setAssignees((prev) => prev.filter((a) => a !== id));
}
function handleAssigneeKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
handleAddAssignee();
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
if (!finishDirectly) {
const name = nextTaskName.trim();
if (!name) return;
const finalAssignees = [...assignees];
if (useWarehouse) {
finalAssignees.push("virtual_warehouse");
}
if (finalAssignees.length === 0) return;
onSubmit(name, finalAssignees, note.trim(), false);
} else {
onSubmit("", [], note.trim(), true);
}
// ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。
// 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。
// 统一改由下方的「打开时重置」负责。
}
function resetForm() {
setNextTaskName("");
setAssignees([]);
setAssigneeInput("");
setUseWarehouse(false);
setFinishDirectly(false);
setNote("");
}
function handleClose() {
resetForm();
onClose();
}
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
const downstreamDisabled = finishDirectly;
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
function handleToggleFinishDirectly(checked: boolean) {
setFinishDirectly(checked);
if (checked) setUseWarehouse(false);
}
const allAssignees = [
...assignees,
...(useWarehouse ? ["virtual_warehouse"] : []),
];
return (
{task.task_name}
{finishDirectly
? "结束本任务且不创建下游任务,产品状态保持原样"
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
);
});
// ============================================================
// 操作弹窗目标类型
// ============================================================
export interface ModalTarget {
task: TaskResponse;
action: "receive" | "reject" | "transfer";
}
// ============================================================
// 主容器组件
// ============================================================
export default function TaskTreeViewer() {
const { toast } = useToast();
// 搜索状态
const [serial, setSerial] = useState("");
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 弹窗状态
const [modalTarget, setModalTarget] = useState(null);
const [submitting, setSubmitting] = useState(false);
// ---- 数据刷新 ----
const refresh = useCallback(async () => {
const trimmed = serial.trim();
if (trimmed.length !== 16) return;
try {
const result = await getTaskTree(trimmed);
setProduct(result);
} catch {
// 静默失败 — 不影响当前展示
}
}, [serial]);
async function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
const trimmed = serial.trim();
if (trimmed.length !== 16) {
setError("请输入 16 位产品身份证");
return;
}
setLoading(true);
setError(null);
setProduct(null);
try {
const result = await getTaskTree(trimmed);
setProduct(result);
} catch (err: any) {
const msg =
err?.response?.status === 404
? `未找到产品身份证 ${trimmed} 对应的产品`
: extractErrorMessage(err, "查询失败,请检查后端服务");
setError(msg);
} finally {
setLoading(false);
}
}
// ---- 操作处理 ----
async function handleReceive() {
if (!modalTarget) return;
setSubmitting(true);
try {
await receiveTask(modalTarget.task.id);
toast("任务已接收 (PENDING → WIP)", "success");
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(extractErrorMessage(err, "接收失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleReject(reason: string, images: string[]) {
if (!modalTarget) return;
setSubmitting(true);
try {
await rejectTask(modalTarget.task.id, reason, images);
toast("任务已驳回,已自动创建返工任务", "success");
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(extractErrorMessage(err, "驳回失败"), "error");
} finally {
setSubmitting(false);
}
}
async function handleTransfer(
nextTaskName: string,
assignees: string[],
note: string,
finishDirectly: boolean
) {
if (!modalTarget) return;
setSubmitting(true);
try {
const result = await transferTask(
modalTarget.task.id,
assignees,
nextTaskName,
note || undefined,
finishDirectly
);
toast(result.message, "success");
setModalTarget(null);
await refresh();
} catch (err: any) {
toast(extractErrorMessage(err, "转交失败"), "error");
} finally {
setSubmitting(false);
}
}
// ---- 渲染 ----
// 🚀 缓存递归计算 — 仅在 task_tree 变化时重新计算
const totalTaskCount = useMemo(
() => (product?.task_tree ? countAllTasks(product.task_tree) : 0),
[product?.task_tree],
);
const modalTask = modalTarget?.task ?? null;
return (
{/* ---- 搜索栏 ---- */}
{/* ---- 错误 ---- */}
{error && (
)}
{/* ---- 加载中 ---- */}
{loading && (
)}
{/* ---- 产品信息摘要 ---- */}
{product && !loading && (
产品身份证
{product.serial_number}
订单编号
{product.order_no || "—"}
当前位置
{product.current_location_id === "virtual_warehouse" ? (
🏭 仓库
) : (
product.current_location_id ?? "—"
)}
任务总数
{product.top_level_tasks?.length ?? 0} 顶层
{product.task_tree?.length
? ` · ${totalTaskCount} 总计`
: ""}
)}
{/* ---- 任务流转分支/卡片视图 ---- */}
{product && !loading && (
<>
{product.task_tree && product.task_tree.length > 0 ? (
) : (
)}
>
)}
{/* ---- 弹窗 ---- */}
setModalTarget(null)}
onConfirm={handleReceive}
/>
setModalTarget(null)}
onSubmit={handleReject}
/>
setModalTarget(null)}
onSubmit={handleTransfer}
/>
);
}
// ============================================================
// 工具函数:递归统计任务总数
// ============================================================
function countAllTasks(tasks: TaskResponse[]): number {
return tasks.reduce((sum, t) => sum + 1 + countAllTasks(t.child_tasks), 0);
}