feat: PC 端补齐直接完结通道
- taskApi.transferTask 增 finishDirectly 参数,按新 Schema 发送
{ next_tasks: [], finish_directly: true, note }
- TaskTransferPayload 支持 next_tasks / finish_directly,旧版单线字段降为可选
(后端仍兼容 next_assignees / next_task_name,存量调用不受影响)
- 共享 TransferModal 新增「🏁 直接完结(无下游,不入库)」开关:
与入库互斥,选中后下游字段禁用(保留输入便于反悔切回)、
切换独立标题/提示/预览/提交按钮,不再套用「将创建 N 个任务」模板
- 表单重置改为「展开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交
- TaskTreeViewer 与 AdminTasksPage 两个入口的 handleTransfer 签名对齐
This commit is contained in:
@ -237,14 +237,35 @@ export const TransferModal = memo(function TransferModal({
|
||||
task: TaskResponse | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (nextTaskName: string, assignees: string[], note: string) => void;
|
||||
onSubmit: (
|
||||
nextTaskName: string,
|
||||
assignees: string[],
|
||||
note: string,
|
||||
finishDirectly: boolean
|
||||
) => void;
|
||||
}) {
|
||||
const [nextTaskName, setNextTaskName] = useState("");
|
||||
const [assigneeInput, setAssigneeInput] = useState("");
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
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() {
|
||||
@ -271,6 +292,9 @@ export const TransferModal = memo(function TransferModal({
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
|
||||
if (!finishDirectly) {
|
||||
const name = nextTaskName.trim();
|
||||
if (!name) return;
|
||||
|
||||
@ -280,35 +304,68 @@ export const TransferModal = memo(function TransferModal({
|
||||
}
|
||||
if (finalAssignees.length === 0) return;
|
||||
|
||||
onSubmit(name, finalAssignees, note.trim());
|
||||
// 重置表单
|
||||
onSubmit(name, finalAssignees, note.trim(), false);
|
||||
} else {
|
||||
onSubmit("", [], note.trim(), true);
|
||||
}
|
||||
// ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。
|
||||
// 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。
|
||||
// 统一改由下方的「打开时重置」负责。
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setNextTaskName("");
|
||||
setAssignees([]);
|
||||
setAssigneeInput("");
|
||||
setUseWarehouse(false);
|
||||
setFinishDirectly(false);
|
||||
setNote("");
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setNextTaskName("");
|
||||
setAssignees([]);
|
||||
setAssigneeInput("");
|
||||
setUseWarehouse(false);
|
||||
setNote("");
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
|
||||
const downstreamDisabled = finishDirectly;
|
||||
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
|
||||
function handleToggleFinishDirectly(checked: boolean) {
|
||||
setFinishDirectly(checked);
|
||||
if (checked) setUseWarehouse(false);
|
||||
}
|
||||
|
||||
const allAssignees = [
|
||||
...assignees,
|
||||
...(useWarehouse ? ["virtual_warehouse"] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title="完工转交 — 裂变到下道工序">
|
||||
<div className="mb-4 rounded-lg bg-green-50 px-3 py-2.5 text-sm text-green-700">
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium">{task.task_name}</p>
|
||||
<p className="mt-0.5 text-xs text-green-500">
|
||||
完成任务并批量创建下一道工序任务,支持多路裂变分支
|
||||
<p
|
||||
className={`mt-0.5 text-xs ${
|
||||
finishDirectly ? "text-amber-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{finishDirectly
|
||||
? "结束本任务且不创建下游任务,产品状态保持原样"
|
||||
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -324,7 +381,8 @@ export const TransferModal = memo(function TransferModal({
|
||||
onChange={(e) => setNextTaskName(e.target.value)}
|
||||
placeholder="如:组装、质检、包装"
|
||||
maxLength={200}
|
||||
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={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"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@ -375,12 +433,13 @@ export const TransferModal = memo(function TransferModal({
|
||||
onKeyDown={handleAssigneeKeyDown}
|
||||
placeholder="输入接收人 ID,按 Enter 添加"
|
||||
maxLength={64}
|
||||
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={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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
disabled={!assigneeInput.trim() || downstreamDisabled}
|
||||
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" />
|
||||
@ -390,11 +449,16 @@ export const TransferModal = memo(function TransferModal({
|
||||
</div>
|
||||
|
||||
{/* 虚拟入库 */}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<label
|
||||
className={`flex items-center gap-2 select-none ${
|
||||
downstreamDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useWarehouse}
|
||||
onChange={(e) => setUseWarehouse(e.target.checked)}
|
||||
disabled={downstreamDisabled}
|
||||
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" />
|
||||
@ -403,6 +467,28 @@ export const TransferModal = memo(function TransferModal({
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* 🏁 直接完结 — 与转交/入库互斥 */}
|
||||
<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>
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
@ -417,8 +503,21 @@ export const TransferModal = memo(function TransferModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预览 — 🏁 直接完结走独立文案,不能套用「将创建 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>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
{allAssignees.length > 0 && nextTaskName.trim() && (
|
||||
{!finishDirectly && allAssignees.length > 0 && nextTaskName.trim() && (
|
||||
<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">
|
||||
@ -460,13 +559,17 @@ export const TransferModal = memo(function TransferModal({
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
!nextTaskName.trim() ||
|
||||
allAssignees.length === 0
|
||||
(!finishDirectly &&
|
||||
(!nextTaskName.trim() || allAssignees.length === 0))
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认转交
|
||||
{finishDirectly ? "🏁 确认直接完结" : "确认转交"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -573,7 +676,8 @@ export default function TaskTreeViewer() {
|
||||
async function handleTransfer(
|
||||
nextTaskName: string,
|
||||
assignees: string[],
|
||||
note: string
|
||||
note: string,
|
||||
finishDirectly: boolean
|
||||
) {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
@ -582,7 +686,8 @@ export default function TaskTreeViewer() {
|
||||
modalTarget.task.id,
|
||||
assignees,
|
||||
nextTaskName,
|
||||
note || undefined
|
||||
note || undefined,
|
||||
finishDirectly
|
||||
);
|
||||
toast(result.message, "success");
|
||||
setModalTarget(null);
|
||||
|
||||
@ -528,12 +528,13 @@ export default function AdminTasksPage() {
|
||||
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,
|
||||
modalTarget.task.id, assignees, nextTaskName, note || undefined, finishDirectly,
|
||||
);
|
||||
toast(result.message, "success");
|
||||
const sn = modalTarget.task.product_sn || "";
|
||||
|
||||
@ -114,20 +114,25 @@ export async function rejectTask(
|
||||
/**
|
||||
* 完工裂变转交 — → COMPLETED,批量创建下家任务。
|
||||
* 对应后端 POST /api/v1/tasks/{taskId}/transfer
|
||||
*
|
||||
* finishDirectly=true 时走「直接完结」通道:next_tasks 留空 + finish_directly=true,
|
||||
* 后端据此跳过分支解析,只闭环任务【不产生下游任务】、且不改产品宏观状态
|
||||
* (不会把产品误标成「待仓库收货」)。用于售后返厂直接发走 / 半成品被提走等无需入库的收官场景。
|
||||
*/
|
||||
export async function transferTask(
|
||||
taskId: string,
|
||||
nextAssignees: string[],
|
||||
nextTaskName: string,
|
||||
note?: string
|
||||
note?: string,
|
||||
finishDirectly = false
|
||||
): Promise<TaskTransferResponse> {
|
||||
const payload: TaskTransferPayload = finishDirectly
|
||||
? { next_tasks: [], finish_directly: true, note }
|
||||
: { next_assignees: nextAssignees, next_task_name: nextTaskName, note };
|
||||
|
||||
const { data } = await api.post<TaskTransferResponse>(
|
||||
`/tasks/${taskId}/transfer`,
|
||||
{
|
||||
next_assignees: nextAssignees,
|
||||
next_task_name: nextTaskName,
|
||||
note,
|
||||
} satisfies TaskTransferPayload
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -86,9 +86,23 @@ export interface TaskRejectPayload {
|
||||
images: string[];
|
||||
}
|
||||
|
||||
/** 裂变分支 — assignees 允许为空数组,但【仅当】finish_directly=true 时合法;
|
||||
* 否则后端 TaskTransferRequest 校验会直接 422 拒绝(防「转交」静默退化成「直接完结」的丢件隐患)。 */
|
||||
export interface TaskTransferBranch {
|
||||
task_name: string;
|
||||
assignees: string[];
|
||||
}
|
||||
|
||||
export interface TaskTransferPayload {
|
||||
next_assignees: string[];
|
||||
next_task_name: string;
|
||||
/** [旧版] 下一道工序接收人列表 — 与 next_tasks 二选一 */
|
||||
next_assignees?: string[];
|
||||
/** [旧版] 下一道工序名称 — 与 next_tasks 二选一 */
|
||||
next_task_name?: string;
|
||||
/** [新版] 多分支任务列表 */
|
||||
next_tasks?: TaskTransferBranch[];
|
||||
/** 🏁 直接完结:闭环当前任务但【不产生任何下游任务】,产品 overall_status 保持原样
|
||||
* (不入库 → 不会变成「待仓库收货」)。置 true 时后端忽略 next_tasks / next_assignees。 */
|
||||
finish_directly?: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user