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/gov-staff/internal/panel.tsx
T
ChaiHaoandClaude Fable 5 6f36f8340a 三面拆分⑩:工作组管理落员工区 —— ADR-011 欠账清账
建组/改组:逐岗位选人,留空即回落四策略。绑定先于策略,起单时快照进工作流;
实测改绑定后新起单的 work_group 快照即含新成员表,在途不受影响。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 21:28:30 +08:00

370 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* feature `gov-staff` —— 员工管理:账号、岗位授权、停用前置检查
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GOVERNANCE_FUNCTION_LABEL, GRANT_ACTION_LABEL, saveWorkGroupRoute, staffIdSchema } from '@contracts';
import type { StaffId } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import {
Acts,
Card,
Chip,
Clear,
Entry,
Field,
NList,
NRow,
Note,
PageHead,
QueryGate,
Rows,
St,
} from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovGrants, useGovStaff, useGovTasks, useGovWorkGroups } from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav };
export function PeopleHome({ tab, nav }: HomeProps) {
if (tab === 'groups') return <WorkGroupsTab />;
return <StaffHome tab={tab} nav={nav} />;
}
function StaffHome({ tab, nav }: HomeProps) {
const q = useGovStaff();
const tasks = useGovTasks();
const staff = q.data?.staff ?? [];
const positions = q.data?.positions ?? [];
if (tab === 'roles') {
return (
<>
<Note>
权限按<b>岗位</b>授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。
人换岗即换权,权限随岗位走。
</Note>
<NList>
{positions.map((p) => (
<NRow
key={p.id}
tone="brand"
ico="key"
title={p.name}
sub={`岗位编码 ${p.code}`}
cols={[{ k: '在岗', v: staff.filter((s) => s.position.id === p.id).length }]}
onClick={() => nav.push({ k: 'role', id: p.id })}
/>
))}
</NList>
</>
);
}
return (
<QueryGate query={q}>
<>
<Note>
停用前置检查:在途任务与待答卡须先<b>改派完毕</b>——停用不得让在途工作无人认领。
</Note>
<NList>
{staff.map((s) => {
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined);
return (
<NRow
key={s.id}
tone={s.state === 'active' ? 'brand' : 'mute'}
ico="user-round"
title={s.name}
sub={`${s.account} · ${s.position.name}(${s.position.code})`}
cols={[{ k: '在途任务', v: mine.length }]}
right={
<St tone={s.state === 'active' ? 'ok' : 'mute'} ico={s.state === 'active' ? 'circle-check' : 'power'}>
{s.state === 'active' ? '在职' : '已停用'}
</St>
}
onClick={() => nav.push({ k: 'emp', id: s.id })}
/>
);
})}
</NList>
</>
</QueryGate>
);
}
/* ---------------- ③ 工作流管理 ---------------- */
export function EmpPage({ id, nav }: { id: string; nav: Nav }) {
const q = useGovStaff();
const tasks = useGovTasks();
const s = (q.data?.staff ?? []).find((x) => x.id === id);
if (s === undefined) return <Note>员工不存在。</Note>;
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name);
const live = mine.filter((t) => t.terminal === undefined);
return (
<>
<PageHead
ico="user-round"
kicker={`员工 · ${s.account}`}
title={s.name}
chips={
<>
<Chip ico="key">
{s.position.name} · {s.position.code}
</Chip>
<Chip tone={s.state === 'active' ? 'ok' : 'mute'}>{s.state === 'active' ? '在职' : '已停用'}</Chip>
</>
}
desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。"
/>
<Card ico="list-checks" title="在途任务" extra={<span className="g-mono">{live.length}</span>}>
<NList>
{live.map((t) => (
<NRow key={t.id} tone="brand" ico="list-checks" title={t.title} onClick={() => nav.push({ k: 'task', id: t.id })} />
))}
{live.length === 0 && <Clear>无在途任务——可安全停用。</Clear>}
</NList>
</Card>
<Entry ico="key" title="岗位授权" desc={`查看 ${s.position.name} 的授权清单`} onClick={() => nav.push({ k: 'role', id: s.position.id })} />
</>
);
}
export function RolePage({ id }: { id: string }) {
const staff = useGovStaff();
const grants = useGovGrants(id);
const position = (staff.data?.positions ?? []).find((p) => p.id === id);
return (
<>
<PageHead
ico="key"
kicker="岗位授权"
title={position?.name ?? id}
chips={<Chip ico="hash">{position?.code ?? '—'}</Chip>}
desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。"
/>
<div className="g-rows">
{(grants.data?.grants ?? []).map((g, i) => (
<div key={i}>
<em>
{g.object.kind === 'data'
? `数据 · ${g.object.domain}.${g.object.object}`
: g.object.kind === 'governance'
? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}`
: `任务 · ${g.object.activityId ?? '全部活动'}`}
</em>
<b className="mono">{GRANT_ACTION_LABEL[g.action]}</b>
</div>
))}
{(grants.data?.grants ?? []).length === 0 && <Clear>未授予任何权限。</Clear>}
</div>
</>
);
}
/* ---------------- 流程包 ---------------- */
/* ---------------- 工作组(ADR-011) ---------------- */
/**
* 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。
*
* 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人;
* 没有组才回落四策略(独任 / 轮询 / 抢单 / 指派)。
* 装进工作流的是**快照** —— 改组不改已起的单,留痕不被改写。
*/
function WorkGroupsTab() {
const groups = useGovWorkGroups();
const staffQ = useGovStaff();
const [editing, setEditing] = useState<string | undefined>(undefined);
const staff = staffQ.data?.staff ?? [];
const positions = staffQ.data?.positions ?? [];
const nameOf = (id: string) => staff.find((s) => s.id === id)?.name ?? id;
const rows = groups.data?.groups ?? [];
return (
<QueryGate query={groups}>
<>
<Note>
工作组把<b>岗位绑到人</b>:谁起的单,整条流的岗位就按谁的组指人 —— 绑定先于策略,
没绑定的岗位仍走四策略。绑定在起单那一刻<b>快照</b>进工作流,此后改组不影响已起的单。
</Note>
{rows.length === 0 ? (
<Clear>还没有工作组 —— 给谁建一个,他起的单整条流就能一次指到人。</Clear>
) : (
rows.map((g) =>
editing === g.ownerId ? (
<WorkGroupForm
key={g.id}
ownerId={g.ownerId}
ownerName={g.ownerName}
name={g.name}
members={g.members}
positions={positions}
staff={staff}
onDone={() => setEditing(undefined)}
onCancel={() => setEditing(undefined)}
/>
) : (
<Card
key={g.id}
ico="users"
title={g.name}
extra={
<button className="g-btn" onClick={() => setEditing(g.ownerId)}>
改绑定
</button>
}
>
<Rows
items={[
{ k: '组长', v: g.ownerName },
...positions.map((p) => ({
k: `${p.name} · ${p.code}`,
v: g.members[p.code] === undefined ? '未绑定 · 走四策略' : nameOf(g.members[p.code]!),
})),
]}
/>
</Card>
),
)
)}
<Card ico="user-round" title="给还没有组的人建一个">
<NList>
{staff
.filter((s) => !rows.some((g) => g.ownerId === s.id))
.map((s) =>
editing === s.id ? null : (
<NRow
key={s.id}
tone="mute"
ico="user-round"
title={s.name}
sub={`${s.position.name} · ${s.position.code}`}
right={<span className="g-mono">建组 →</span>}
onClick={() => setEditing(s.id)}
/>
),
)}
{staff.every((s) => rows.some((g) => g.ownerId === s.id)) && <Clear>每个人都有组了。</Clear>}
</NList>
</Card>
{staff
.filter((s) => !rows.some((g) => g.ownerId === s.id) && editing === s.id)
.map((s) => (
<WorkGroupForm
key={s.id}
ownerId={s.id}
ownerName={s.name}
name={`${s.name}的工作组`}
members={{}}
positions={positions}
staff={staff}
onDone={() => setEditing(undefined)}
onCancel={() => setEditing(undefined)}
/>
))}
</>
</QueryGate>
);
}
/** 建组 / 改组表单:逐岗位选人,留空即该岗位不绑定 */
function WorkGroupForm({
ownerId,
ownerName,
name,
members,
positions,
staff,
onDone,
onCancel,
}: {
ownerId: string;
ownerName: string;
name: string;
members: Readonly<Record<string, string>>;
positions: ReadonlyArray<{ id: string; code: string; name: string }>;
staff: ReadonlyArray<{ id: string; name: string; position: { code: string; name: string } }>;
onDone: () => void;
onCancel: () => void;
}) {
const queryClient = useQueryClient();
const [draftName, setDraftName] = useState(name);
const [draft, setDraft] = useState<Record<string, string>>({ ...members });
const save = useMutation({
mutationFn: (input: { name: string; members: Record<string, StaffId> }) =>
call(saveWorkGroupRoute, {
body: { ownerId: staffIdSchema.parse(ownerId), name: input.name, members: input.members },
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['gov', 'work-groups'] });
onDone();
},
});
const bound = Object.values(draft).filter((v) => v !== '').length;
return (
<Card ico="users" title={`工作组:${ownerName}`}>
<Field k="组名" hint="人读的一句话,如「柴浩的工作组」">
<input className="g-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
</Field>
{positions.map((p) => (
<Field key={p.id} k={`${p.name} · ${p.code}`} hint="留空即不绑定,该岗位仍走四策略">
<select
className="g-input"
value={draft[p.code] ?? ''}
onChange={(e) => setDraft({ ...draft, [p.code]: e.target.value })}
>
<option value="">— 不绑定 —</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}({s.position.code})
</option>
))}
</select>
</Field>
))}
<Acts
align="end"
items={[
{ label: '取消', ico: 'x', onClick: onCancel },
{
label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`,
ico: 'circle-check',
primary: true,
disabled: save.isPending || draftName.trim() === '',
ask: {
title: `保存工作组:${draftName}`,
note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>,
impact: {
who: '仅此后新起的工作流;在途不受影响 —— 绑定是起单时的快照',
undo: '可随时改;解绑某岗位即回落四策略',
when: '确认后即时生效',
},
okLabel: '确认保存',
onOk: () => {
const picked: Record<string, StaffId> = {};
for (const [code, id] of Object.entries(draft)) {
if (id !== '') picked[code] = staffIdSchema.parse(id);
}
save.mutate({ name: draftName, members: picked });
},
},
},
]}
/>
{save.isError && <Note tone="warn">保存失败,请重试。</Note>}
</Card>
);
}