2026-08-28 11:24:59 +08:00
|
|
|
|
import { useState, useCallback, useMemo, useEffect, memo } from "react";
|
2026-08-04 17:30:26 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Search,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
AlertCircle,
|
|
|
|
|
|
GitBranch,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
X,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Warehouse,
|
|
|
|
|
|
UserPlus,
|
|
|
|
|
|
} from "lucide-react";
|
2026-08-07 11:44:12 +08:00
|
|
|
|
import TaskFlowView from "./TaskFlowView";
|
2026-08-04 17:30:26 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getTaskTree,
|
|
|
|
|
|
receiveTask,
|
|
|
|
|
|
rejectTask,
|
|
|
|
|
|
transferTask,
|
|
|
|
|
|
} from "../../services/taskApi";
|
|
|
|
|
|
import { useToast } from "../ui/Toast";
|
2026-08-07 11:44:12 +08:00
|
|
|
|
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
|
|
|
|
|
import { getStatusConfig } from "../../constants/task";
|
2026-09-15 10:57:56 +08:00
|
|
|
|
import ImageUploader from "../ui/ImageUploader";
|
|
|
|
|
|
import { extractErrorMessage } from "../../utils/errorMessage";
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 通用 Modal 容器
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-08-28 11:19:08 +08:00
|
|
|
|
export const Modal = memo(function Modal({
|
2026-08-04 17:30:26 +08:00
|
|
|
|
open,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
title,
|
|
|
|
|
|
children,
|
2026-08-28 11:19:08 +08:00
|
|
|
|
widthClass = "max-w-md",
|
|
|
|
|
|
bodyClassName = "",
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
children: React.ReactNode;
|
2026-08-28 11:19:08 +08:00
|
|
|
|
widthClass?: string;
|
|
|
|
|
|
bodyClassName?: string;
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}) {
|
2026-08-28 11:24:59 +08:00
|
|
|
|
// 🚀 弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
const prev = document.body.style.overflow;
|
|
|
|
|
|
document.body.style.overflow = "hidden";
|
|
|
|
|
|
return () => { document.body.style.overflow = prev; };
|
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
2026-08-04 17:30:26 +08:00
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
|
|
|
|
{/* 遮罩 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{/* 弹窗 */}
|
2026-08-28 11:19:08 +08:00
|
|
|
|
<div className={`relative z-10 mx-4 w-full ${widthClass} rounded-xl bg-white p-6 shadow-2xl ${bodyClassName}`}>
|
2026-08-04 17:30:26 +08:00
|
|
|
|
<div className="mb-4 flex items-center justify-between">
|
|
|
|
|
|
<h3 className="text-base font-bold text-gray-800">{title}</h3>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-08-06 17:33:54 +08:00
|
|
|
|
});
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 确认接收弹窗
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-08-07 11:44:12 +08:00
|
|
|
|
export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
|
2026-08-04 17:30:26 +08:00
|
|
|
|
open,
|
|
|
|
|
|
task,
|
|
|
|
|
|
submitting,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onConfirm,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
task: TaskResponse | null;
|
|
|
|
|
|
submitting: boolean;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onConfirm: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!task) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Modal open={open} onClose={onClose} title="确认接收任务">
|
|
|
|
|
|
<div className="mb-6 rounded-lg bg-blue-50 px-3 py-2.5 text-sm text-blue-700">
|
|
|
|
|
|
<p className="font-medium">{task.task_name}</p>
|
|
|
|
|
|
<p className="mt-0.5 text-xs text-blue-500">
|
|
|
|
|
|
当前状态: {getStatusConfig(task.status).label}
|
|
|
|
|
|
{" → "}
|
|
|
|
|
|
<span className="font-semibold">进行中 (WIP)</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mb-6 text-sm text-gray-500">
|
|
|
|
|
|
确认接收后,该任务将标记为"进行中",系统将记录接收时间。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onConfirm}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
|
|
|
|
确认接收
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
2026-08-06 17:33:54 +08:00
|
|
|
|
});
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 品质驳回弹窗
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-08-07 11:44:12 +08:00
|
|
|
|
export const RejectModal = memo(function RejectModal({
|
2026-08-04 17:30:26 +08:00
|
|
|
|
open,
|
|
|
|
|
|
task,
|
|
|
|
|
|
submitting,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onSubmit,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
task: TaskResponse | null;
|
|
|
|
|
|
submitting: boolean;
|
|
|
|
|
|
onClose: () => void;
|
2026-09-15 10:57:56 +08:00
|
|
|
|
onSubmit: (reason: string, images: string[]) => void;
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const [reason, setReason] = useState("");
|
2026-09-15 10:57:56 +08:00
|
|
|
|
const [images, setImages] = useState<string[]>([]);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
if (!task) return null;
|
|
|
|
|
|
|
|
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const trimmed = reason.trim();
|
|
|
|
|
|
if (!trimmed) return;
|
2026-09-15 10:57:56 +08:00
|
|
|
|
// 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交
|
|
|
|
|
|
onSubmit(trimmed, images);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
setReason("");
|
2026-09-15 10:57:56 +08:00
|
|
|
|
setImages([]);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleClose() {
|
|
|
|
|
|
setReason("");
|
2026-09-15 10:57:56 +08:00
|
|
|
|
setImages([]);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Modal open={open} onClose={handleClose} title="品质驳回">
|
|
|
|
|
|
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
|
|
|
|
|
<p className="font-medium">{task.task_name}</p>
|
|
|
|
|
|
<p className="mt-0.5 text-xs text-red-500">
|
|
|
|
|
|
⚠ 驳回后将自动生成返工任务,分配给上一道工序负责人
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
|
|
|
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
|
驳回原因 <span className="text-red-500">*</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={reason}
|
|
|
|
|
|
onChange={(e) => setReason(e.target.value)}
|
|
|
|
|
|
placeholder="请详细说明品质驳回的原因(必填)"
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
maxLength={500}
|
2026-09-15 10:57:56 +08:00
|
|
|
|
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
|
2026-08-04 17:30:26 +08:00
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
2026-09-15 10:57:56 +08:00
|
|
|
|
<div className="mb-3 mt-1 text-right text-xs text-gray-400">{reason.length}/500</div>
|
|
|
|
|
|
|
|
|
|
|
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
|
异常图片 <span className="text-xs font-normal text-gray-400">(选填,最多 9 张)</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<ImageUploader
|
|
|
|
|
|
value={images}
|
|
|
|
|
|
onChange={setImages}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 flex items-center justify-end gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={submitting || !reason.trim()}
|
|
|
|
|
|
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
|
|
|
|
确认驳回
|
|
|
|
|
|
</button>
|
2026-08-04 17:30:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
2026-08-06 17:33:54 +08:00
|
|
|
|
});
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 完工裂变转交弹窗
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-08-07 11:44:12 +08:00
|
|
|
|
export const TransferModal = memo(function TransferModal({
|
2026-08-04 17:30:26 +08:00
|
|
|
|
open,
|
|
|
|
|
|
task,
|
|
|
|
|
|
submitting,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onSubmit,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
task: TaskResponse | null;
|
|
|
|
|
|
submitting: boolean;
|
|
|
|
|
|
onClose: () => void;
|
2026-09-17 14:07:05 +08:00
|
|
|
|
onSubmit: (
|
|
|
|
|
|
nextTaskName: string,
|
|
|
|
|
|
assignees: string[],
|
|
|
|
|
|
note: string,
|
|
|
|
|
|
finishDirectly: boolean
|
|
|
|
|
|
) => void;
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const [nextTaskName, setNextTaskName] = useState("");
|
|
|
|
|
|
const [assigneeInput, setAssigneeInput] = useState("");
|
|
|
|
|
|
const [assignees, setAssignees] = useState<string[]>([]);
|
|
|
|
|
|
const [useWarehouse, setUseWarehouse] = useState(false);
|
2026-09-17 14:07:05 +08:00
|
|
|
|
// 🏁 直接完结:无下游、不入库。与「转交个人 / 入库」互斥
|
|
|
|
|
|
const [finishDirectly, setFinishDirectly] = useState(false);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
const [note, setNote] = useState("");
|
|
|
|
|
|
|
2026-09-17 14:07:05 +08:00
|
|
|
|
// 🔄 每次打开都重置: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]);
|
|
|
|
|
|
|
2026-08-04 17:30:26 +08:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-09-17 14:07:05 +08:00
|
|
|
|
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
|
|
|
|
|
|
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);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
// ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。
|
|
|
|
|
|
// 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。
|
|
|
|
|
|
// 统一改由下方的「打开时重置」负责。
|
|
|
|
|
|
}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
2026-09-17 14:07:05 +08:00
|
|
|
|
function resetForm() {
|
2026-08-04 17:30:26 +08:00
|
|
|
|
setNextTaskName("");
|
|
|
|
|
|
setAssignees([]);
|
|
|
|
|
|
setAssigneeInput("");
|
|
|
|
|
|
setUseWarehouse(false);
|
2026-09-17 14:07:05 +08:00
|
|
|
|
setFinishDirectly(false);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
setNote("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleClose() {
|
2026-09-17 14:07:05 +08:00
|
|
|
|
resetForm();
|
2026-08-04 17:30:26 +08:00
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 14:07:05 +08:00
|
|
|
|
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
|
|
|
|
|
|
const downstreamDisabled = finishDirectly;
|
|
|
|
|
|
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
|
|
|
|
|
|
function handleToggleFinishDirectly(checked: boolean) {
|
|
|
|
|
|
setFinishDirectly(checked);
|
|
|
|
|
|
if (checked) setUseWarehouse(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 17:30:26 +08:00
|
|
|
|
const allAssignees = [
|
|
|
|
|
|
...assignees,
|
|
|
|
|
|
...(useWarehouse ? ["virtual_warehouse"] : []),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-17 14:07:05 +08:00
|
|
|
|
<Modal
|
|
|
|
|
|
open={open}
|
|
|
|
|
|
onClose={handleClose}
|
|
|
|
|
|
title={
|
|
|
|
|
|
finishDirectly
|
|
|
|
|
|
? "完工转交 — 🏁 直接完结(无下游,不入库)"
|
|
|
|
|
|
: "完工转交 — 裂变到下道工序"
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`mb-4 rounded-lg px-3 py-2.5 text-sm ${
|
|
|
|
|
|
finishDirectly
|
|
|
|
|
|
? "bg-amber-50 text-amber-700"
|
|
|
|
|
|
: "bg-green-50 text-green-700"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-08-04 17:30:26 +08:00
|
|
|
|
<p className="font-medium">{task.task_name}</p>
|
2026-09-17 14:07:05 +08:00
|
|
|
|
<p
|
|
|
|
|
|
className={`mt-0.5 text-xs ${
|
|
|
|
|
|
finishDirectly ? "text-amber-500" : "text-green-500"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{finishDirectly
|
|
|
|
|
|
? "结束本任务且不创建下游任务,产品状态保持原样"
|
|
|
|
|
|
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
|
|
|
|
{/* 下道工序名称 */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
|
下一道工序名称 <span className="text-red-500">*</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={nextTaskName}
|
|
|
|
|
|
onChange={(e) => setNextTaskName(e.target.value)}
|
|
|
|
|
|
placeholder="如:组装、质检、包装"
|
|
|
|
|
|
maxLength={200}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
disabled={downstreamDisabled}
|
|
|
|
|
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
|
2026-08-04 17:30:26 +08:00
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 接收人(多选) */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="mb-1 flex items-center gap-1 text-sm font-medium text-gray-700">
|
|
|
|
|
|
<UserPlus className="h-3.5 w-3.5" />
|
|
|
|
|
|
下一道工序接收人 <span className="text-red-500">*</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 已添加的接收人标签 */}
|
|
|
|
|
|
{allAssignees.length > 0 && (
|
|
|
|
|
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
|
|
|
|
{allAssignees.map((a) => (
|
|
|
|
|
|
<span
|
|
|
|
|
|
key={a}
|
|
|
|
|
|
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium ${
|
|
|
|
|
|
a === "virtual_warehouse"
|
|
|
|
|
|
? "bg-purple-100 text-purple-700"
|
|
|
|
|
|
: "bg-blue-100 text-blue-700"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{a === "virtual_warehouse" ? (
|
|
|
|
|
|
<Warehouse className="h-3 w-3" />
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{a === "virtual_warehouse" ? "仓库" : a}
|
|
|
|
|
|
{a !== "virtual_warehouse" && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => handleRemoveAssignee(a)}
|
|
|
|
|
|
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-2.5 w-2.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 输入 + 添加按钮 */}
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={assigneeInput}
|
|
|
|
|
|
onChange={(e) => setAssigneeInput(e.target.value)}
|
|
|
|
|
|
onKeyDown={handleAssigneeKeyDown}
|
|
|
|
|
|
placeholder="输入接收人 ID,按 Enter 添加"
|
|
|
|
|
|
maxLength={64}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
disabled={downstreamDisabled}
|
|
|
|
|
|
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
|
2026-08-04 17:30:26 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleAddAssignee}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
disabled={!assigneeInput.trim() || downstreamDisabled}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
className="flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="h-3.5 w-3.5" />
|
|
|
|
|
|
添加
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 虚拟入库 */}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
<label
|
|
|
|
|
|
className={`flex items-center gap-2 select-none ${
|
|
|
|
|
|
downstreamDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-08-04 17:30:26 +08:00
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={useWarehouse}
|
|
|
|
|
|
onChange={(e) => setUseWarehouse(e.target.checked)}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
disabled={downstreamDisabled}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Warehouse className="h-4 w-4 text-purple-500" />
|
|
|
|
|
|
<span className="text-sm text-gray-700">
|
|
|
|
|
|
同时入库(产品 current_location → virtual_warehouse)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
2026-09-17 14:07:05 +08:00
|
|
|
|
{/* 🏁 直接完结 — 与转交/入库互斥 */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={finishDirectly}
|
|
|
|
|
|
onChange={(e) => handleToggleFinishDirectly(e.target.checked)}
|
|
|
|
|
|
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-sm font-medium text-gray-700">
|
|
|
|
|
|
🏁 直接完结(无下游,不入库)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{finishDirectly && (
|
|
|
|
|
|
<p className="mt-1.5 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
|
|
|
|
|
任务将直接完结,不创建任何下游任务;产品宏观状态保持不变,
|
|
|
|
|
|
<span className="font-semibold">不会进入「待仓库收货」</span>
|
|
|
|
|
|
。适用于售后返厂直接发走、半成品被直接提走等无需入库的收官场景。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-04 17:30:26 +08:00
|
|
|
|
{/* 备注 */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
|
交接备注 <span className="text-gray-400 font-normal">(可选)</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={note}
|
|
|
|
|
|
onChange={(e) => setNote(e.target.value)}
|
|
|
|
|
|
placeholder="转交附言、注意事项等"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-17 14:07:05 +08:00
|
|
|
|
{/* 预览 — 🏁 直接完结走独立文案,不能套用「将创建 N 个任务」模板 */}
|
|
|
|
|
|
{finishDirectly && (
|
|
|
|
|
|
<div className="rounded-lg bg-amber-50 px-3 py-2.5 text-xs text-amber-700">
|
|
|
|
|
|
<p className="font-medium">🏁 直接完结预览:</p>
|
|
|
|
|
|
<p className="mt-1">
|
|
|
|
|
|
任务「<span className="font-semibold">{task.task_name}</span>
|
|
|
|
|
|
」将直接结束,
|
|
|
|
|
|
<span className="font-semibold">不创建任何下游任务</span>
|
|
|
|
|
|
;产品宏观状态与当前位置保持不变。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-04 17:30:26 +08:00
|
|
|
|
{/* 预览 */}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
{!finishDirectly && allAssignees.length > 0 && nextTaskName.trim() && (
|
2026-08-04 17:30:26 +08:00
|
|
|
|
<div className="rounded-lg bg-gray-50 px-3 py-2.5 text-xs text-gray-500">
|
|
|
|
|
|
<p className="font-medium text-gray-600">裂变预览:</p>
|
|
|
|
|
|
<p className="mt-1">
|
|
|
|
|
|
将创建 <span className="font-semibold text-gray-700">
|
|
|
|
|
|
{allAssignees.length}
|
|
|
|
|
|
</span>{" "}
|
|
|
|
|
|
个「
|
|
|
|
|
|
<span className="font-semibold text-gray-700">
|
|
|
|
|
|
{nextTaskName.trim()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
」任务
|
|
|
|
|
|
{allAssignees.length > 1 && (
|
|
|
|
|
|
<span className="text-purple-600">
|
|
|
|
|
|
{" "}
|
|
|
|
|
|
— 触发裂变,父任务为当前任务
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="mt-0.5">
|
|
|
|
|
|
接收人:{" "}
|
|
|
|
|
|
{allAssignees
|
|
|
|
|
|
.map((a) => (a === "virtual_warehouse" ? "仓库" : a))
|
|
|
|
|
|
.join(", ")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 按钮 */}
|
|
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
submitting ||
|
2026-09-17 14:07:05 +08:00
|
|
|
|
(!finishDirectly &&
|
|
|
|
|
|
(!nextTaskName.trim() || allAssignees.length === 0))
|
2026-08-04 17:30:26 +08:00
|
|
|
|
}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${
|
|
|
|
|
|
finishDirectly
|
|
|
|
|
|
? "bg-amber-600 hover:bg-amber-700"
|
|
|
|
|
|
: "bg-green-600 hover:bg-green-700"
|
|
|
|
|
|
}`}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
>
|
|
|
|
|
|
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
2026-09-17 14:07:05 +08:00
|
|
|
|
{finishDirectly ? "🏁 确认直接完结" : "确认转交"}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
2026-08-06 17:33:54 +08:00
|
|
|
|
});
|
2026-08-04 17:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-08-07 11:44:12 +08:00
|
|
|
|
// 操作弹窗目标类型
|
2026-08-04 17:30:26 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-08-07 11:44:12 +08:00
|
|
|
|
export interface ModalTarget {
|
2026-08-04 17:30:26 +08:00
|
|
|
|
task: TaskResponse;
|
|
|
|
|
|
action: "receive" | "reject" | "transfer";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 主容器组件
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
export default function TaskTreeViewer() {
|
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
|
|
|
|
|
|
// 搜索状态
|
|
|
|
|
|
const [serial, setSerial] = useState("");
|
|
|
|
|
|
const [product, setProduct] = useState<ProductScanResponse | null>(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 弹窗状态
|
|
|
|
|
|
const [modalTarget, setModalTarget] = useState<ModalTarget | null>(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) {
|
2026-08-07 11:44:12 +08:00
|
|
|
|
setError("请输入 16 位产品身份证");
|
2026-08-04 17:30:26 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
setProduct(null);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await getTaskTree(trimmed);
|
|
|
|
|
|
setProduct(result);
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
const msg =
|
|
|
|
|
|
err?.response?.status === 404
|
2026-08-07 11:44:12 +08:00
|
|
|
|
? `未找到产品身份证 ${trimmed} 对应的产品`
|
2026-09-15 10:57:56 +08:00
|
|
|
|
: extractErrorMessage(err, "查询失败,请检查后端服务");
|
2026-08-04 17:30:26 +08:00
|
|
|
|
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) {
|
2026-09-15 10:57:56 +08:00
|
|
|
|
toast(extractErrorMessage(err, "接收失败"), "error");
|
2026-08-04 17:30:26 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 10:57:56 +08:00
|
|
|
|
async function handleReject(reason: string, images: string[]) {
|
2026-08-04 17:30:26 +08:00
|
|
|
|
if (!modalTarget) return;
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
2026-09-15 10:57:56 +08:00
|
|
|
|
await rejectTask(modalTarget.task.id, reason, images);
|
2026-08-04 17:30:26 +08:00
|
|
|
|
toast("任务已驳回,已自动创建返工任务", "success");
|
|
|
|
|
|
setModalTarget(null);
|
|
|
|
|
|
await refresh();
|
|
|
|
|
|
} catch (err: any) {
|
2026-09-15 10:57:56 +08:00
|
|
|
|
toast(extractErrorMessage(err, "驳回失败"), "error");
|
2026-08-04 17:30:26 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleTransfer(
|
|
|
|
|
|
nextTaskName: string,
|
|
|
|
|
|
assignees: string[],
|
2026-09-17 14:07:05 +08:00
|
|
|
|
note: string,
|
|
|
|
|
|
finishDirectly: boolean
|
2026-08-04 17:30:26 +08:00
|
|
|
|
) {
|
|
|
|
|
|
if (!modalTarget) return;
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await transferTask(
|
|
|
|
|
|
modalTarget.task.id,
|
|
|
|
|
|
assignees,
|
|
|
|
|
|
nextTaskName,
|
2026-09-17 14:07:05 +08:00
|
|
|
|
note || undefined,
|
|
|
|
|
|
finishDirectly
|
2026-08-04 17:30:26 +08:00
|
|
|
|
);
|
|
|
|
|
|
toast(result.message, "success");
|
|
|
|
|
|
setModalTarget(null);
|
|
|
|
|
|
await refresh();
|
|
|
|
|
|
} catch (err: any) {
|
2026-09-15 10:57:56 +08:00
|
|
|
|
toast(extractErrorMessage(err, "转交失败"), "error");
|
2026-08-04 17:30:26 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 渲染 ----
|
|
|
|
|
|
|
2026-08-06 17:33:54 +08:00
|
|
|
|
// 🚀 缓存递归计算 — 仅在 task_tree 变化时重新计算
|
|
|
|
|
|
const totalTaskCount = useMemo(
|
|
|
|
|
|
() => (product?.task_tree ? countAllTasks(product.task_tree) : 0),
|
|
|
|
|
|
[product?.task_tree],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-04 17:30:26 +08:00
|
|
|
|
const modalTask = modalTarget?.task ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{/* ---- 搜索栏 ---- */}
|
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
|
<h2 className="text-xl font-bold text-gray-800">任务全景树</h2>
|
|
|
|
|
|
<p className="mt-1 text-sm text-gray-500">
|
2026-08-07 11:44:12 +08:00
|
|
|
|
输入 16 位产品身份证,查看完整任务流转十字矩阵树状图
|
2026-08-04 17:30:26 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSearch} className="mt-4 flex items-center gap-2">
|
|
|
|
|
|
<div className="relative flex-1 max-w-md">
|
|
|
|
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={serial}
|
|
|
|
|
|
onChange={(e) => setSerial(e.target.value)}
|
2026-08-07 11:44:12 +08:00
|
|
|
|
placeholder="输入 16 位产品身份证,如 X20260801000001"
|
2026-08-04 17:30:26 +08:00
|
|
|
|
maxLength={16}
|
|
|
|
|
|
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 font-mono text-sm tracking-widest placeholder:tracking-normal placeholder:font-sans focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Search className="h-4 w-4" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
查询
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{product && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => handleSearch()}
|
|
|
|
|
|
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-500 transition-colors hover:bg-gray-50"
|
|
|
|
|
|
title="刷新"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ---- 错误 ---- */}
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<div className="mb-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
|
|
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ---- 加载中 ---- */}
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="flex items-center justify-center py-20">
|
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ---- 产品信息摘要 ---- */}
|
|
|
|
|
|
{product && !loading && (
|
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-white p-4 shadow-sm">
|
|
|
|
|
|
<div>
|
2026-08-07 11:44:12 +08:00
|
|
|
|
<span className="text-xs text-gray-400">产品身份证</span>
|
2026-08-04 17:30:26 +08:00
|
|
|
|
<p className="font-mono text-base font-bold tracking-widest text-gray-800">
|
|
|
|
|
|
{product.serial_number}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hidden h-8 w-px bg-gray-200 sm:block" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-xs text-gray-400">订单编号</span>
|
|
|
|
|
|
<p className="text-sm font-medium text-gray-600">
|
|
|
|
|
|
{product.order_no || "—"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hidden h-8 w-px bg-gray-200 sm:block" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-xs text-gray-400">当前位置</span>
|
|
|
|
|
|
<p className="text-sm font-medium text-gray-600">
|
|
|
|
|
|
{product.current_location_id === "virtual_warehouse" ? (
|
|
|
|
|
|
<span className="inline-flex items-center gap-1 rounded bg-purple-50 px-1.5 py-0.5 text-xs font-medium text-purple-600">
|
|
|
|
|
|
🏭 仓库
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
product.current_location_id ?? "—"
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hidden h-8 w-px bg-gray-200 sm:block" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="text-xs text-gray-400">任务总数</span>
|
|
|
|
|
|
<p className="text-sm font-semibold text-gray-800">
|
|
|
|
|
|
{product.top_level_tasks?.length ?? 0} 顶层
|
|
|
|
|
|
{product.task_tree?.length
|
2026-08-06 17:33:54 +08:00
|
|
|
|
? ` · ${totalTaskCount} 总计`
|
2026-08-04 17:30:26 +08:00
|
|
|
|
: ""}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-07 16:51:56 +08:00
|
|
|
|
{/* ---- 任务流转分支/卡片视图 ---- */}
|
2026-08-04 17:30:26 +08:00
|
|
|
|
{product && !loading && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{product.task_tree && product.task_tree.length > 0 ? (
|
2026-08-07 11:44:12 +08:00
|
|
|
|
<div className="rounded-xl bg-white p-6 shadow-sm overflow-hidden">
|
|
|
|
|
|
<TaskFlowView
|
|
|
|
|
|
tasks={product.task_tree}
|
|
|
|
|
|
onAction={setModalTarget}
|
|
|
|
|
|
/>
|
2026-08-04 17:30:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 text-gray-400 shadow-sm">
|
|
|
|
|
|
<GitBranch className="mb-3 h-10 w-10" />
|
|
|
|
|
|
<p className="text-sm">该产品暂无关联任务</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ---- 弹窗 ---- */}
|
|
|
|
|
|
<ReceiveConfirmModal
|
|
|
|
|
|
open={modalTarget?.action === "receive"}
|
|
|
|
|
|
task={modalTask}
|
|
|
|
|
|
submitting={submitting}
|
|
|
|
|
|
onClose={() => setModalTarget(null)}
|
|
|
|
|
|
onConfirm={handleReceive}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<RejectModal
|
|
|
|
|
|
open={modalTarget?.action === "reject"}
|
|
|
|
|
|
task={modalTask}
|
|
|
|
|
|
submitting={submitting}
|
|
|
|
|
|
onClose={() => setModalTarget(null)}
|
|
|
|
|
|
onSubmit={handleReject}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<TransferModal
|
|
|
|
|
|
open={modalTarget?.action === "transfer"}
|
|
|
|
|
|
task={modalTask}
|
|
|
|
|
|
submitting={submitting}
|
|
|
|
|
|
onClose={() => setModalTarget(null)}
|
|
|
|
|
|
onSubmit={handleTransfer}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 工具函数:递归统计任务总数
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
function countAllTasks(tasks: TaskResponse[]): number {
|
|
|
|
|
|
return tasks.reduce((sum, t) => sum + 1 + countAllTasks(t.child_tasks), 0);
|
|
|
|
|
|
}
|