221 行
12 KiB
TypeScript
221 行
12 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 { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
|
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 [confirmDefinitionAction, setConfirmDefinitionAction] = useState<{
|
|
action: "publish" | "archive";
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
|
|
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]);
|
|
const workflowStats = {
|
|
total: items.length,
|
|
scoped: scopedItems.length,
|
|
draft: items.filter((item) => item.status === "draft").length,
|
|
published: items.filter((item) => item.status === "published").length,
|
|
archived: items.filter((item) => item.status === "archived").length,
|
|
};
|
|
|
|
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 (
|
|
<ListWorkspace>
|
|
<WorkspaceHeader title="流程定义" subtitle="线性流程版本治理,发布动作需确认。" />
|
|
<div className="finance-status-strip" data-testid="workflow-definitions-summary">
|
|
<div className="finance-metric-card"><div className="finance-metric-card__label">定义总数</div><div className="finance-metric-card__value">{workflowStats.total}</div></div>
|
|
<div className="finance-metric-card"><div className="finance-metric-card__label">当前业务域</div><div className="finance-metric-card__value">{workflowStats.scoped}</div></div>
|
|
<div className="finance-metric-card"><div className="finance-metric-card__label">草稿</div><div className="finance-metric-card__value">{workflowStats.draft}</div></div>
|
|
<div className="finance-metric-card"><div className="finance-metric-card__label">已发布</div><div className="finance-metric-card__value">{workflowStats.published}</div></div>
|
|
<div className="finance-metric-card"><div className="finance-metric-card__label">已归档</div><div className="finance-metric-card__value">{workflowStats.archived}</div></div>
|
|
</div>
|
|
<div className="admin-workspace-grid">
|
|
<div className="admin-command-panel space-y-3">
|
|
<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="admin-command-panel space-y-3">
|
|
<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 ? "finance-list-card finance-list-card--active" : "finance-list-card"} 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="admin-command-panel space-y-4">
|
|
{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={() => setConfirmDefinitionAction({ action: "publish", id: selected.id, name: selected.name })}
|
|
disabled={selected.status === "archived"}
|
|
>
|
|
发布
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setConfirmDefinitionAction({ action: "archive", id: selected.id, name: selected.name })}
|
|
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="detail-evidence-card">
|
|
<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>
|
|
<ActionConfirmDialog
|
|
open={Boolean(confirmDefinitionAction)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setConfirmDefinitionAction(null);
|
|
}}
|
|
kind={confirmDefinitionAction?.action === "archive" ? "archive" : "submit"}
|
|
targetName={confirmDefinitionAction?.name}
|
|
title={confirmDefinitionAction?.action === "archive" ? "确认归档流程定义" : "确认发布流程定义"}
|
|
description={
|
|
confirmDefinitionAction?.action === "archive"
|
|
? "归档后该流程定义不再用于新流程,历史流程不回写。"
|
|
: "发布后该流程定义会参与后续新提交业务的审批解析,历史流程不回写。"
|
|
}
|
|
onConfirm={async () => {
|
|
if (!confirmDefinitionAction) return;
|
|
if (confirmDefinitionAction.action === "publish") publishMutation.mutate(confirmDefinitionAction.id);
|
|
if (confirmDefinitionAction.action === "archive") archiveMutation.mutate(confirmDefinitionAction.id);
|
|
}}
|
|
/>
|
|
</ListWorkspace>
|
|
);
|
|
}
|