Archived
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
/**
|
||
* 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}>
|
||
<>
|
||
<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: draftName.trim() === '',
|
||
busy: save.isPending,
|
||
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>
|
||
);
|
||
}
|