forked from ChaiHao/BPOS-MVP
三面拆分⑩:工作组管理落员工区 —— ADR-011 欠账清账
建组/改组:逐岗位选人,留空即回落四策略。绑定先于策略,起单时快照进工作流; 实测改绑定后新起单的 work_group 快照即含新成员表,在途不受影响。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -5,21 +5,37 @@
|
||||
* 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 {
|
||||
GOVERNANCE_FUNCTION_LABEL,
|
||||
GRANT_ACTION_LABEL,
|
||||
} from '@contracts';
|
||||
import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
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,
|
||||
} from '@web-shared/gov/gov-api.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 ?? [];
|
||||
@@ -153,3 +169,201 @@ export function RolePage({ id }: { id: string }) {
|
||||
|
||||
/* ---------------- 流程包 ---------------- */
|
||||
|
||||
|
||||
/* ---------------- 工作组(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
GovTasksOutput,
|
||||
GovToolsOutput,
|
||||
GovTriggersOutput,
|
||||
GovWorkGroupsOutput,
|
||||
GovWorkflowOutput,
|
||||
ProcessPackage,
|
||||
} from '@contracts';
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
listStaffRoute,
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
listWorkGroupsRoute,
|
||||
validateProcessRoute,
|
||||
} from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
@@ -105,6 +107,11 @@ export const useGovPkgReports = (
|
||||
},
|
||||
});
|
||||
|
||||
export type GovWorkGroups = GovWorkGroupsOutput;
|
||||
|
||||
export const useGovWorkGroups = (): UseQueryResult<GovWorkGroups> =>
|
||||
useQuery({ queryKey: ['gov', 'work-groups'], queryFn: () => call<GovWorkGroups>(listWorkGroupsRoute) });
|
||||
|
||||
export const useGovTriggers = (): UseQueryResult<GovTriggersOutput> =>
|
||||
useQuery({ queryKey: ['gov', 'triggers'], queryFn: () => call<GovTriggersOutput>(listTriggersRoute) });
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
|
||||
people: [
|
||||
{ key: 'emp', label: '员工', ico: 'users' },
|
||||
{ key: 'roles', label: '岗位授权', ico: 'key' },
|
||||
{ key: 'groups', label: '工作组', ico: 'users' },
|
||||
],
|
||||
ledgers: [],
|
||||
knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }],
|
||||
|
||||
Reference in New Issue
Block a user