feat(P25-2/P25-3/P26-2/P28-1/P24-2/P29-3): 落地内部OA够用版前端入口与运营页面
关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
这个提交包含在:
@@ -0,0 +1,168 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户