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;
|
task: TaskResponse | null;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
onClose: () => void;
|
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 [nextTaskName, setNextTaskName] = useState("");
|
||||||
const [assigneeInput, setAssigneeInput] = useState("");
|
const [assigneeInput, setAssigneeInput] = useState("");
|
||||||
const [assignees, setAssignees] = useState<string[]>([]);
|
const [assignees, setAssignees] = useState<string[]>([]);
|
||||||
const [useWarehouse, setUseWarehouse] = useState(false);
|
const [useWarehouse, setUseWarehouse] = useState(false);
|
||||||
|
// 🏁 直接完结:无下游、不入库。与「转交个人 / 入库」互斥
|
||||||
|
const [finishDirectly, setFinishDirectly] = useState(false);
|
||||||
const [note, setNote] = useState("");
|
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;
|
if (!task) return null;
|
||||||
|
|
||||||
function handleAddAssignee() {
|
function handleAddAssignee() {
|
||||||
@ -271,6 +292,9 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
|
||||||
|
if (!finishDirectly) {
|
||||||
const name = nextTaskName.trim();
|
const name = nextTaskName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
@ -280,35 +304,68 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
}
|
}
|
||||||
if (finalAssignees.length === 0) return;
|
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("");
|
setNextTaskName("");
|
||||||
setAssignees([]);
|
setAssignees([]);
|
||||||
setAssigneeInput("");
|
setAssigneeInput("");
|
||||||
setUseWarehouse(false);
|
setUseWarehouse(false);
|
||||||
|
setFinishDirectly(false);
|
||||||
setNote("");
|
setNote("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setNextTaskName("");
|
resetForm();
|
||||||
setAssignees([]);
|
|
||||||
setAssigneeInput("");
|
|
||||||
setUseWarehouse(false);
|
|
||||||
setNote("");
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
|
||||||
|
const downstreamDisabled = finishDirectly;
|
||||||
|
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
|
||||||
|
function handleToggleFinishDirectly(checked: boolean) {
|
||||||
|
setFinishDirectly(checked);
|
||||||
|
if (checked) setUseWarehouse(false);
|
||||||
|
}
|
||||||
|
|
||||||
const allAssignees = [
|
const allAssignees = [
|
||||||
...assignees,
|
...assignees,
|
||||||
...(useWarehouse ? ["virtual_warehouse"] : []),
|
...(useWarehouse ? ["virtual_warehouse"] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={handleClose} title="完工转交 — 裂变到下道工序">
|
<Modal
|
||||||
<div className="mb-4 rounded-lg bg-green-50 px-3 py-2.5 text-sm text-green-700">
|
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="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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -324,7 +381,8 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
onChange={(e) => setNextTaskName(e.target.value)}
|
onChange={(e) => setNextTaskName(e.target.value)}
|
||||||
placeholder="如:组装、质检、包装"
|
placeholder="如:组装、质检、包装"
|
||||||
maxLength={200}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -375,12 +433,13 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
onKeyDown={handleAssigneeKeyDown}
|
onKeyDown={handleAssigneeKeyDown}
|
||||||
placeholder="输入接收人 ID,按 Enter 添加"
|
placeholder="输入接收人 ID,按 Enter 添加"
|
||||||
maxLength={64}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddAssignee}
|
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"
|
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" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
@ -390,11 +449,16 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useWarehouse}
|
checked={useWarehouse}
|
||||||
onChange={(e) => setUseWarehouse(e.target.checked)}
|
onChange={(e) => setUseWarehouse(e.target.checked)}
|
||||||
|
disabled={downstreamDisabled}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
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" />
|
<Warehouse className="h-4 w-4 text-purple-500" />
|
||||||
@ -403,6 +467,28 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
@ -417,8 +503,21 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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="font-medium text-gray-600">裂变预览:</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
@ -460,13 +559,17 @@ export const TransferModal = memo(function TransferModal({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
submitting ||
|
submitting ||
|
||||||
!nextTaskName.trim() ||
|
(!finishDirectly &&
|
||||||
allAssignees.length === 0
|
(!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" />}
|
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
确认转交
|
{finishDirectly ? "🏁 确认直接完结" : "确认转交"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -573,7 +676,8 @@ export default function TaskTreeViewer() {
|
|||||||
async function handleTransfer(
|
async function handleTransfer(
|
||||||
nextTaskName: string,
|
nextTaskName: string,
|
||||||
assignees: string[],
|
assignees: string[],
|
||||||
note: string
|
note: string,
|
||||||
|
finishDirectly: boolean
|
||||||
) {
|
) {
|
||||||
if (!modalTarget) return;
|
if (!modalTarget) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@ -582,7 +686,8 @@ export default function TaskTreeViewer() {
|
|||||||
modalTarget.task.id,
|
modalTarget.task.id,
|
||||||
assignees,
|
assignees,
|
||||||
nextTaskName,
|
nextTaskName,
|
||||||
note || undefined
|
note || undefined,
|
||||||
|
finishDirectly
|
||||||
);
|
);
|
||||||
toast(result.message, "success");
|
toast(result.message, "success");
|
||||||
setModalTarget(null);
|
setModalTarget(null);
|
||||||
|
|||||||
@ -528,12 +528,13 @@ export default function AdminTasksPage() {
|
|||||||
nextTaskName: string,
|
nextTaskName: string,
|
||||||
assignees: string[],
|
assignees: string[],
|
||||||
note: string,
|
note: string,
|
||||||
|
finishDirectly: boolean,
|
||||||
) {
|
) {
|
||||||
if (!modalTarget) return;
|
if (!modalTarget) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await transferTask(
|
const result = await transferTask(
|
||||||
modalTarget.task.id, assignees, nextTaskName, note || undefined,
|
modalTarget.task.id, assignees, nextTaskName, note || undefined, finishDirectly,
|
||||||
);
|
);
|
||||||
toast(result.message, "success");
|
toast(result.message, "success");
|
||||||
const sn = modalTarget.task.product_sn || "";
|
const sn = modalTarget.task.product_sn || "";
|
||||||
|
|||||||
@ -114,20 +114,25 @@ export async function rejectTask(
|
|||||||
/**
|
/**
|
||||||
* 完工裂变转交 — → COMPLETED,批量创建下家任务。
|
* 完工裂变转交 — → COMPLETED,批量创建下家任务。
|
||||||
* 对应后端 POST /api/v1/tasks/{taskId}/transfer
|
* 对应后端 POST /api/v1/tasks/{taskId}/transfer
|
||||||
|
*
|
||||||
|
* finishDirectly=true 时走「直接完结」通道:next_tasks 留空 + finish_directly=true,
|
||||||
|
* 后端据此跳过分支解析,只闭环任务【不产生下游任务】、且不改产品宏观状态
|
||||||
|
* (不会把产品误标成「待仓库收货」)。用于售后返厂直接发走 / 半成品被提走等无需入库的收官场景。
|
||||||
*/
|
*/
|
||||||
export async function transferTask(
|
export async function transferTask(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
nextAssignees: string[],
|
nextAssignees: string[],
|
||||||
nextTaskName: string,
|
nextTaskName: string,
|
||||||
note?: string
|
note?: string,
|
||||||
|
finishDirectly = false
|
||||||
): Promise<TaskTransferResponse> {
|
): 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>(
|
const { data } = await api.post<TaskTransferResponse>(
|
||||||
`/tasks/${taskId}/transfer`,
|
`/tasks/${taskId}/transfer`,
|
||||||
{
|
payload
|
||||||
next_assignees: nextAssignees,
|
|
||||||
next_task_name: nextTaskName,
|
|
||||||
note,
|
|
||||||
} satisfies TaskTransferPayload
|
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,9 +86,23 @@ export interface TaskRejectPayload {
|
|||||||
images: string[];
|
images: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 裂变分支 — assignees 允许为空数组,但【仅当】finish_directly=true 时合法;
|
||||||
|
* 否则后端 TaskTransferRequest 校验会直接 422 拒绝(防「转交」静默退化成「直接完结」的丢件隐患)。 */
|
||||||
|
export interface TaskTransferBranch {
|
||||||
|
task_name: string;
|
||||||
|
assignees: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskTransferPayload {
|
export interface TaskTransferPayload {
|
||||||
next_assignees: string[];
|
/** [旧版] 下一道工序接收人列表 — 与 next_tasks 二选一 */
|
||||||
next_task_name: string;
|
next_assignees?: string[];
|
||||||
|
/** [旧版] 下一道工序名称 — 与 next_tasks 二选一 */
|
||||||
|
next_task_name?: string;
|
||||||
|
/** [新版] 多分支任务列表 */
|
||||||
|
next_tasks?: TaskTransferBranch[];
|
||||||
|
/** 🏁 直接完结:闭环当前任务但【不产生任何下游任务】,产品 overall_status 保持原样
|
||||||
|
* (不入库 → 不会变成「待仓库收货」)。置 true 时后端忽略 next_tasks / next_assignees。 */
|
||||||
|
finish_directly?: boolean;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user