feat: add StaffRolesCard component for managing license type staff requirements and integrate with existing API endpoints.

This commit is contained in:
estifanos
2026-08-31 10:26:36 +00:00
parent e5117b1837
commit eb338f66b9
4 changed files with 490 additions and 0 deletions

View File

@@ -36,6 +36,7 @@ import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { FieldEditorDrawer } from './FieldEditorDrawer';
import { SectionEditorDrawer } from './SectionEditorDrawer';
import { StaffRolesCard } from './StaffRolesCard';
function moveItem<T>(list: T[], index: number, direction: -1 | 1): T[] {
const target = index + direction;
@@ -284,6 +285,8 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
</Stack>
)}
<StaffRolesCard licenseType={licenseType} />
<SectionEditorDrawer
opened={sectionDrawer !== null}
onClose={() => setSectionDrawer(null)}

View File

@@ -0,0 +1,443 @@
import { useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Checkbox,
Divider,
Drawer,
Group,
Modal,
NumberInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter, PageLoader } from '@ema-platform/ui';
import {
useCreateStaffRoleRequirementMutation,
useDeleteStaffRoleRequirementMutation,
useGetStaffRoleRequirementsQuery,
useLocalized,
useUpdateStaffRoleRequirementMutation,
type LicenseType,
type StaffEvidenceRequirement,
type StaffRoleRequirement,
} from '@ema-platform/api';
import { useRequirementActions } from '../hooks/useRequirementActions';
const ROLE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
type Draft = Omit<StaffRoleRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
function emptyDraft(sortOrder: number): Draft {
return {
roleKey: '',
name: { en: '', am: '' },
minCount: 1,
maxCount: null,
requiresExperience: false,
minYearsExperience: null,
requiredEvidence: [],
sortOrder,
};
}
/**
* The staff a licence type demands, and the evidence each of them must
* supply — the wizard's Staff step, which lives in its own table rather than
* in `formSchema` and so had no editor at all: an administrator could see FF
* asking for two ERB-certified transit employees but could not change it
* without a re-seed.
*
* Rows save on confirm, like the document requirements tab — deliberately not
* folded into the schema draft above, whose "Save schema" button replaces one
* jsonb column in a single PUT.
*/
export function StaffRolesCard({ licenseType }: { licenseType: LicenseType }) {
const { t } = useTranslation();
const localized = useLocalized();
const run = useRequirementActions();
const { data, isLoading } = useGetStaffRoleRequirementsQuery();
const [createRole, { isLoading: creating }] = useCreateStaffRoleRequirementMutation();
const [updateRole, { isLoading: updating }] = useUpdateStaffRoleRequirementMutation();
const [deleteRole] = useDeleteStaffRoleRequirementMutation();
const [editing, setEditing] = useState<{ role: StaffRoleRequirement | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<StaffRoleRequirement | null>(null);
const roles = useMemo(
() =>
(data?.items ?? [])
.filter((r) => r.licenseTypeId === licenseType.id)
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder),
[data, licenseType.id],
);
async function handleSave(draft: Draft) {
const target = editing?.role;
const ok = await run(
() =>
target
? updateRole({ id: target.id, ...draft }).unwrap()
: createRole({ ...draft, licenseTypeId: licenseType.id }).unwrap(),
target
? t('certReq.staff.updated', 'Staff role updated')
: t('certReq.staff.created', 'Staff role added'),
);
if (ok) setEditing(null);
}
async function confirmDelete() {
if (!deleteTarget) return;
const ok = await run(
() => deleteRole(deleteTarget.id).unwrap(),
t('certReq.staff.deleted', 'Staff role removed'),
);
if (ok) setDeleteTarget(null);
}
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" mb="sm">
<div>
<Title order={5}>{t('certReq.staff.title', 'Staff roles')}</Title>
<Text fz="xs" c="dimmed">
{t(
'certReq.staff.subtitle',
'The Staff step of this licence type: who the applicant must register and the evidence each of them uploads. Saved on confirm, separately from the sections above.',
)}
</Text>
</div>
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() => setEditing({ role: null })}
>
{t('certReq.staff.add', 'Add staff role')}
</Button>
</Group>
{isLoading ? (
<PageLoader label={t('certReq.staff.loading', 'Loading staff roles…')} height={120} />
) : roles.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="md">
{t('certReq.staff.empty', 'No staff roles — this licence type has no Staff step.')}
</Text>
) : (
<Stack gap="xs">
{roles.map((role) => (
<Card key={role.id} withBorder radius="sm" p="xs" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Group gap={6}>
<Text fz="sm" fw={600} truncate>{localized(role.name) || role.roleKey}</Text>
<Badge size="xs" variant="light">
{role.maxCount != null && role.maxCount === role.minCount
? t('certReq.staff.exactly', 'exactly {{n}}', { n: role.minCount })
: t('certReq.staff.range', 'min {{min}}{{max}}', {
min: role.minCount,
max: role.maxCount != null ? ` · max ${role.maxCount}` : '',
})}
</Badge>
{role.requiresExperience && (
<Badge size="xs" color="violet" variant="light">
{t('certReq.staff.experience', 'experience')}
{role.minYearsExperience ? ` · ${role.minYearsExperience}y` : ''}
</Badge>
)}
</Group>
<Text fz="xs" c="dimmed" truncate>
key: {role.roleKey}
{role.requiredEvidence.length > 0 &&
` · ${role.requiredEvidence
.map((e) => `${e.docKey}${e.mandatory ? '*' : ''}`)
.join(', ')}`}
</Text>
</div>
<Group gap={4} wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => setEditing({ role })}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(role)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
))}
</Stack>
)}
<StaffRoleEditorDrawer
opened={editing !== null}
onClose={() => setEditing(null)}
role={editing?.role ?? null}
nextSortOrder={roles.length + 1}
onSave={handleSave}
saving={creating || updating}
/>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={t('certReq.staff.delete', 'Delete staff role')}
size="sm"
>
<Stack gap="md">
<Text fz="sm">
{t('certReq.staff.deleteConfirm', 'Remove "{{name}}" from this licence type\'s Staff step?', {
name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.roleKey : '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>
{t('certReq.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={confirmDelete}>{t('certReq.delete', 'Delete')}</Button>
</ModalFooter>
</Stack>
</Modal>
</Card>
);
}
function StaffRoleEditorDrawer({
opened,
onClose,
role,
nextSortOrder,
onSave,
saving,
}: {
opened: boolean;
onClose: () => void;
/** Null = adding a new role. */
role: StaffRoleRequirement | null;
nextSortOrder: number;
onSave: (draft: Draft) => void;
saving: boolean;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Draft>(emptyDraft(nextSortOrder));
const [keyError, setKeyError] = useState<string | null>(null);
const isNew = !role;
useEffect(() => {
if (!opened) return;
setDraft(
role
? {
roleKey: role.roleKey,
name: { ...role.name },
minCount: role.minCount,
maxCount: role.maxCount,
requiresExperience: role.requiresExperience,
minYearsExperience: role.minYearsExperience,
requiredEvidence: role.requiredEvidence.map((e) => ({ ...e, label: { ...e.label } })),
sortOrder: role.sortOrder,
}
: emptyDraft(nextSortOrder),
);
setKeyError(null);
}, [opened, role, nextSortOrder]);
function patchEvidence(index: number, patch: Partial<StaffEvidenceRequirement>) {
setDraft((d) => ({
...d,
requiredEvidence: d.requiredEvidence.map((e, i) => (i === index ? { ...e, ...patch } : e)),
}));
}
function save() {
const roleKey = draft.roleKey.trim();
if (!ROLE_KEY_PATTERN.test(roleKey)) {
setKeyError(
t(
'certReq.staff.keyInvalid',
'Key must start with a letter and contain only letters, numbers, underscores',
),
);
return;
}
if (!draft.name.en?.trim()) return;
// An evidence row with no docKey renders an upload slot nothing can be
// attached to, so it is dropped rather than saved half-filled.
onSave({
...draft,
roleKey,
requiredEvidence: draft.requiredEvidence.filter((e) => e.docKey.trim()),
});
}
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={
<Text fw={700}>
{isNew ? t('certReq.staff.add', 'Add staff role') : t('certReq.staff.edit', 'Edit staff role')}
</Text>
}
>
<Stack gap="md">
<TextInput
label={t('certReq.staff.roleKey', 'Role key')}
placeholder="TRANSIT_CUSTOMS"
required
value={draft.roleKey}
error={keyError}
disabled={!isNew}
description={
isNew
? t('certReq.staff.roleKeyHelp', 'Letters, numbers and underscores only — identifies the role on submitted staff')
: t('certReq.staff.roleKeyLocked', 'Key cannot change once created — staff already registered reference it')
}
onChange={(e) => {
const { value } = e.currentTarget;
setDraft((d) => ({ ...d, roleKey: value }));
}}
/>
<BilingualInput
label={t('certReq.staff.name', 'Role name')}
required
value={{ en: draft.name.en ?? '', am: draft.name.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, name: v }))}
/>
<Group grow>
<NumberInput
label={t('certReq.staff.minCount', 'Minimum people')}
min={0}
value={draft.minCount}
onChange={(v) => setDraft((d) => ({ ...d, minCount: typeof v === 'number' ? v : 0 }))}
description={t('certReq.staff.minCountHelp', '0 makes the role optional')}
/>
<NumberInput
label={t('certReq.staff.maxCount', 'Maximum people')}
min={0}
value={draft.maxCount ?? ''}
onChange={(v) => setDraft((d) => ({ ...d, maxCount: typeof v === 'number' ? v : null }))}
description={t('certReq.staff.maxCountHelp', 'Leave empty for no limit')}
/>
</Group>
<Checkbox
label={t('certReq.staff.requiresExperience', 'Requires prior experience')}
checked={draft.requiresExperience}
onChange={(e) => {
const { checked } = e.currentTarget;
setDraft((d) => ({
...d,
requiresExperience: checked,
minYearsExperience: checked ? d.minYearsExperience : null,
}));
}}
/>
{draft.requiresExperience && (
<NumberInput
label={t('certReq.staff.minYears', 'Minimum years of experience')}
min={0}
value={draft.minYearsExperience ?? ''}
onChange={(v) =>
setDraft((d) => ({ ...d, minYearsExperience: typeof v === 'number' ? v : null }))
}
/>
)}
<NumberInput
label={t('certReq.staff.sortOrder', 'Sort order')}
value={draft.sortOrder}
onChange={(v) => setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : 0 }))}
/>
<Divider label={t('certReq.staff.evidence', 'Required evidence')} labelPosition="left" />
<Text fz="xs" c="dimmed">
{t(
'certReq.staff.evidenceHelp',
'One upload slot per document, asked of every person registered in this role.',
)}
</Text>
{draft.requiredEvidence.map((evidence, i) => (
<Card key={i} withBorder radius="sm" p="xs">
<Stack gap="xs">
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
size="xs"
label={t('certReq.staff.docKey', 'Document key')}
placeholder="cv"
value={evidence.docKey}
onChange={(e) => patchEvidence(i, { docKey: e.currentTarget.value })}
style={{ flex: 1 }}
/>
<BilingualInput
size="xs"
label={t('certReq.staff.docLabel', 'Label')}
value={{ en: evidence.label.en ?? '', am: evidence.label.am ?? '' }}
onChange={(v) => patchEvidence(i, { label: v })}
style={{ flex: 2 }}
/>
<Button
size="xs"
color="red"
variant="subtle"
px={6}
onClick={() =>
setDraft((d) => ({
...d,
requiredEvidence: d.requiredEvidence.filter((_, j) => j !== i),
}))
}
>
<IconTrash size={14} />
</Button>
</Group>
<Checkbox
size="xs"
label={t('certReq.staff.mandatory', 'Mandatory')}
checked={evidence.mandatory}
onChange={(e) => patchEvidence(i, { mandatory: e.currentTarget.checked })}
/>
</Stack>
</Card>
))}
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={13} />}
onClick={() =>
setDraft((d) => ({
...d,
requiredEvidence: [
...d.requiredEvidence,
{ docKey: '', label: { en: '', am: '' }, mandatory: true },
],
}))
}
>
{t('certReq.staff.addEvidence', 'Add evidence')}
</Button>
<ModalFooter>
<Button variant="default" onClick={onClose}>{t('certReq.cancel', 'Cancel')}</Button>
<Button color="teal" loading={saving} onClick={save}>
{isNew ? t('certReq.staff.add', 'Add staff role') : t('certReq.saveChanges', 'Save changes')}
</Button>
</ModalFooter>
</Stack>
</Drawer>
);
}

View File

@@ -40,6 +40,7 @@ import type {
SavedQueueView,
SchemaIssue,
ServiceKind,
StaffRoleRequirement,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
@@ -99,6 +100,7 @@ const TAGS = [
'PickupAppointment',
'Department',
'Rank',
'StaffRoleRequirement',
// Owned by the personal-document slice; named here so declaring a mode of
// operation can invalidate the vault, whose slots depend on it.
'PersonalDocument',
@@ -366,6 +368,42 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
}),
// ----------------------------------------------- staff role requirements
/**
* Every staff role requirement, filtered by licence type at the call
* site — same reasoning as `getDocumentRequirements`: small
* configuration data with no pagination need.
*/
getStaffRoleRequirements: builder.query<Paginated<StaffRoleRequirement>, void>({
query: () => ({ url: '/staff-role-requirements' }),
providesTags: () => [listTag('StaffRoleRequirement')],
}),
createStaffRoleRequirement: builder.mutation<
StaffRoleRequirement,
Partial<StaffRoleRequirement> & { licenseTypeId: string; roleKey: string; name: StaffRoleRequirement['name'] }
>({
query: (body) => ({ url: '/staff-role-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
updateStaffRoleRequirement: builder.mutation<
StaffRoleRequirement,
{ id: string } & Partial<StaffRoleRequirement>
>({
query: ({ id, ...body }) => ({
url: `/staff-role-requirements/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
deleteStaffRoleRequirement: builder.mutation<unknown, string>({
query: (id) => ({ url: `/staff-role-requirements/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]),
}),
// --------------------------------------------------- departments & ranks
/** Every department, for the admin editor. */
getDepartments: builder.query<Paginated<Department>, void>({
@@ -1341,6 +1379,10 @@ export const {
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
useGetStaffRoleRequirementsQuery,
useCreateStaffRoleRequirementMutation,
useUpdateStaffRoleRequirementMutation,
useDeleteStaffRoleRequirementMutation,
useGetDepartmentsQuery,
useGetActiveDepartmentsQuery,
useCreateDepartmentMutation,

View File

@@ -364,6 +364,7 @@ export interface StaffEvidenceRequirement {
export interface StaffRoleRequirement {
id: string;
licenseTypeId: string;
roleKey: string;
name: Bilingual;
minCount: number;
@@ -372,6 +373,7 @@ export interface StaffRoleRequirement {
minYearsExperience: number | null;
requiredEvidence: StaffEvidenceRequirement[];
sortOrder: number;
isActive: boolean;
}
export interface LicenseTypeRequirements {