关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
169 行
8.7 KiB
TypeScript
169 行
8.7 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
archiveWorkflowDefinition,
|
|
createWorkflowDefinition,
|
|
fetchWorkflowDefinitions,
|
|
publishWorkflowDefinition,
|
|
updateWorkflowDefinition,
|
|
type WorkflowDefinitionEditorModel,
|
|
} from "../api/workflowDefinitions";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { Button } from "../components/ui/button";
|
|
import { Input } from "../components/ui/input";
|
|
import { Label } from "../components/ui/label";
|
|
import { toast } from "sonner";
|
|
|
|
const ENTITY_TYPES: WorkflowDefinitionEditorModel["entityType"][] = ["reimbursement", "leave", "purchase_request", "travel_request"];
|
|
|
|
type DraftStep = WorkflowDefinitionEditorModel["steps"][number];
|
|
|
|
function emptyStep(): DraftStep {
|
|
return { id: `step_${Date.now()}`, name: "审批节点", assigneeRule: { type: "department_manager" } };
|
|
}
|
|
|
|
export function WorkflowDefinitionsPage() {
|
|
const queryClient = useQueryClient();
|
|
const [entityType, setEntityType] = useState<WorkflowDefinitionEditorModel["entityType"]>("leave");
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const [name, setName] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
const [steps, setSteps] = useState<DraftStep[]>([emptyStep()]);
|
|
|
|
const listQuery = useQuery({ queryKey: ["workflow-definitions"], queryFn: () => fetchWorkflowDefinitions() });
|
|
const items = listQuery.data?.items ?? [];
|
|
const scopedItems = useMemo(() => items.filter((item) => item.entityType === entityType), [items, entityType]);
|
|
const selected = useMemo(() => scopedItems.find((item) => item.id === selectedId) ?? scopedItems[0] ?? null, [scopedItems, selectedId]);
|
|
|
|
useEffect(() => {
|
|
if (!selected) return;
|
|
setSelectedId(selected.id);
|
|
setName(selected.name);
|
|
setNotes(selected.notes ?? "");
|
|
setSteps(selected.steps.length ? selected.steps : [emptyStep()]);
|
|
}, [selected?.id]);
|
|
|
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ["workflow-definitions"] });
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () => createWorkflowDefinition({ entityType, name: name.trim() || `${entityType}-workflow`, notes, steps }),
|
|
onSuccess: async ({ item }) => {
|
|
setSelectedId(item.id);
|
|
await refresh();
|
|
toast.success("流程定义草稿已创建");
|
|
},
|
|
});
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: (id: string) => updateWorkflowDefinition(id, { name, notes, steps }),
|
|
onSuccess: async () => {
|
|
await refresh();
|
|
toast.success("流程定义已保存");
|
|
},
|
|
});
|
|
|
|
const publishMutation = useMutation({
|
|
mutationFn: publishWorkflowDefinition,
|
|
onSuccess: async () => {
|
|
await refresh();
|
|
toast.success("流程定义已发布");
|
|
},
|
|
});
|
|
|
|
const archiveMutation = useMutation({
|
|
mutationFn: archiveWorkflowDefinition,
|
|
onSuccess: async () => {
|
|
await refresh();
|
|
toast.success("流程定义已归档");
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" />
|
|
<div className="grid gap-6 xl:grid-cols-[220px,280px,1fr]">
|
|
<div className="card-std space-y-3 p-6">
|
|
<Label>业务域</Label>
|
|
<select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as WorkflowDefinitionEditorModel["entityType"])}>
|
|
{ENTITY_TYPES.map((item) => <option key={item} value={item}>{item}</option>)}
|
|
</select>
|
|
{entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null}
|
|
</div>
|
|
|
|
<div className="card-std space-y-3 p-6">
|
|
<h3 className="text-base font-semibold text-zinc-900">版本列表</h3>
|
|
<div className="space-y-2">
|
|
{scopedItems.map((item) => (
|
|
<button key={item.id} type="button" className={selected?.id === item.id ? "w-full rounded-lg border border-primary bg-primary/5 px-3 py-3 text-left" : "w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"} onClick={() => setSelectedId(item.id)}>
|
|
<div className="font-medium text-zinc-900">v{item.version} · {item.name}</div>
|
|
<div className="text-xs text-zinc-500">{item.status} · {item.source}</div>
|
|
</button>
|
|
))}
|
|
{!scopedItems.length ? <div className="text-sm text-zinc-500">当前实体暂无流程定义</div> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std space-y-4 p-6">
|
|
{selected ? (
|
|
<>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">{selected.source}</div>
|
|
<div className="text-lg font-semibold text-zinc-900">{selected.name}</div>
|
|
</div>
|
|
{selected.status !== "derived_readonly" ? (
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate(selected.id)} disabled={selected.status !== "draft"}>保存</Button>
|
|
<Button type="button" onClick={() => publishMutation.mutate(selected.id)} disabled={selected.status === "archived"}>发布</Button>
|
|
<Button type="button" variant="secondary" onClick={() => archiveMutation.mutate(selected.id)} disabled={selected.status === "archived"}>归档</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>名称</Label>
|
|
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={selected.status !== "draft"} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>备注</Label>
|
|
<Input value={notes} onChange={(event) => setNotes(event.target.value)} disabled={selected.status !== "draft"} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-base font-semibold text-zinc-900">步骤</h3>
|
|
{selected.status === "draft" ? <Button type="button" variant="secondary" onClick={() => setSteps((prev) => [...prev, emptyStep()])}>新增步骤</Button> : null}
|
|
</div>
|
|
<div className="space-y-3">
|
|
{steps.map((step, index) => (
|
|
<div key={step.id} className="rounded-lg border border-zinc-200 p-4">
|
|
<div className="grid gap-3 md:grid-cols-3">
|
|
<Input value={step.id} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, id: event.target.value } : item))} />
|
|
<Input value={step.name} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, name: event.target.value } : item))} />
|
|
<select className="select-std" value={step.assigneeRule.type} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, assigneeRule: event.target.value === "role" ? { type: "role", value: "manager" } : event.target.value === "specific_user" ? { type: "specific_user", value: "" } : { type: "department_manager" } } : item))}>
|
|
<option value="department_manager">department_manager</option>
|
|
<option value="role">role</option>
|
|
<option value="specific_user">specific_user</option>
|
|
</select>
|
|
</div>
|
|
{step.assigneeRule.type !== "department_manager" ? (
|
|
<div className="mt-3">
|
|
<Input value={step.assigneeRule.value} disabled={selected.status !== "draft"} onChange={(event) => setSteps((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, assigneeRule: { ...item.assigneeRule, value: event.target.value } as DraftStep["assigneeRule"] } : item))} placeholder={step.assigneeRule.type === "role" ? "角色编码" : "用户 ID"} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-sm text-zinc-500">暂无可编辑流程定义</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|