This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/web/src/features/desk-approval/internal/ApprovalDesk.tsx
T

194 lines
8.1 KiB
TypeScript

/**
* 审批要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
* 的 `ApprovalsModule` / `ApprovalDetail` 平移;mock 数据换成服务端投影。
*
* 「工作台层面的『审批』**专指工作流级的审批点**」:它卡到审批人工位,产出审批单——
* 通过 / 驳回,附依据。**没有预算、没有 Harness、没有任务日志,所以不是任务。**
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ApprovalItem, ListApprovalsForMeOutput } from '@contracts';
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
const STATE_CHIP = {
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
passed: { tone: 'ok' as const, label: '已通过', pulse: false },
rejected: { tone: 'warn' as const, label: '已退回', pulse: false },
};
export function ApprovalDesk() {
const queryClient = useQueryClient();
const [selId, setSelId] = useState<string | undefined>(undefined);
const [filter, setFilter] = useState<'todo' | 'done'>('todo');
const [comment, setComment] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const q = useQuery({
queryKey: ['approvals'],
queryFn: () => call<ListApprovalsForMeOutput>(listApprovalsForMeRoute),
});
const approvals = q.data?.approvals ?? [];
const todo = approvals.filter((a) => a.state === 'pending');
const done = approvals.filter((a) => a.state !== 'pending');
const visible = filter === 'todo' ? todo : done;
const sel = approvals.find((a) => a.id === selId) ?? visible[0];
const decide = useMutation({
mutationFn: (input: { item: ApprovalItem; verdict: 'pass' | 'reject' }) =>
call(decideApprovalRoute, {
params: { workflowId: input.item.workflowId, nodeId: input.item.nodeId },
body: { verdict: input.verdict, ...(comment.trim() === '' ? {} : { comment: comment.trim() }) },
}),
onSuccess: () => {
setComment('');
setError(undefined);
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
onError: (e: unknown) => setError((e as { message?: string }).message ?? '裁决失败'),
});
return (
<div className="workspace">
<aside className="task-sidebar">
<SidebarHead icon="file-check" title="审批中心" sub={`等我放行的事项 · ${todo.length} 件待批`} />
<div className="jc-flt-wrap" aria-label="审批筛选">
<div className="jc-flt" role="tablist" aria-label="按状态">
<button className={filter === 'todo' ? 'active' : ''} onClick={() => setFilter('todo')}>
<span>待我审批</span>
<strong>{todo.length}</strong>
</button>
<button className={filter === 'done' ? 'active' : ''} onClick={() => setFilter('done')}>
<span>已处理</span>
<strong>{done.length}</strong>
</button>
</div>
</div>
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
{visible.map((a) => {
const chip = STATE_CHIP[a.state];
return (
<button
key={a.id}
className={'jc-mcard jc-acard' + (a.id === sel?.id ? ' selected' : '')}
onClick={() => setSelId(a.id)}
>
<span className="jc-mcard-ico" style={{ background: 'linear-gradient(140deg, #3370ff, #245bdb)' }}>
<Ico name="shield-check" s={15} />
</span>
<span className="jc-mcard-body">
<span className="jc-mcard-top">
<em>审批点签批</em>
<Chip tone={chip.tone} label={chip.label} pulse={chip.pulse} />
</span>
<strong className="jc-mcard-title jc-clamp2">{a.title}</strong>
<span className="jc-acard-from">
<Ico name="workflow" s={12} />
<b>{a.workflowSubject}</b>
<time className="jc-mono">{a.raisedAt.slice(5, 16).replace('T', ' ')}</time>
</span>
</span>
</button>
);
})}
{visible.length === 0 && (
<ListEmpty icon="circle-check" title={filter === 'todo' ? '待批清空了' : '还没有处理记录'}>
{filter === 'todo'
? '没有等你放行的事项 —— 有新的审批单会第一时间到这里。'
: '你处理过的审批会留在这里,随时回看当时的依据与裁决。'}
</ListEmpty>
)}
</ScrollArea>
</aside>
{sel === undefined ? (
<main className="execution-panel jc-panel-card">
<EmptyState
ico="shield-check"
tone="ok"
title="没有等你放行的事项"
desc="流程走到审批点时,审批单会连同它的依据一起送到这里 —— 你只需要看一眼就能拍板。"
/>
</main>
) : (
<DocPanel scrollKey={sel.id}>
<Hero
grad={['#3370ff', '#245bdb']}
icon="shield-check"
kicker={`审批点 · ${sel.nodeId}`}
title={sel.title}
meta={
<>
<Ico name="workflow" s={14} />
<b>{sel.workflowSubject}</b>
<span className="jc-dim">工作流 {sel.workflowId.slice(0, 8)}</span>
</>
}
chip={<Chip tone={STATE_CHIP[sel.state].tone} label={STATE_CHIP[sel.state].label} />}
/>
<Section icon="clipboard-check" title="依据快照" extra="调用必附上下文 · 裸调用不得发出">
<div className="jc-kv">
{sel.basis.map((b) => (
<div key={b.k} className="jc-kv-i">
<i>{b.k}</i>
<b className="jc-mono">{b.v}</b>
</div>
))}
{sel.basis.length === 0 && <span className="jc-dim">上游尚无输出口载荷</span>}
</div>
</Section>
<Section icon="git-branch" title="驳回走向" extra="声明式 · 不由临场决定">
<NoteBand>{sel.rejectRoute}</NoteBand>
</Section>
{sel.state === 'pending' ? (
<Section icon="pen-line" title="签批意见" extra="驳回必附意见">
<textarea
className="jc-sup"
rows={3}
placeholder="写下裁决依据(驳回时必填)"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
<div className="jc-mod-actions">
<button
type="button"
className="card-btn"
disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'reject' })}
>
<Ico name="rotate-ccw" s={13} />
驳回
</button>
<button
type="button"
className="card-btn primary"
disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'pass' })}
>
<Ico name="check" s={13} />
通过
</button>
</div>
</Section>
) : (
<div className="jc-mod-actions">
<span className="jc-mod-donehint">
<Ico name="circle-check" s={15} />
已裁决 · 流程按声明继续
</span>
</div>
)}
</DocPanel>
)}
</div>
);
}