文件
hyb-oa/client/src/pages/WorkflowDefinitionsPage.tsx
T

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>
);
}