diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx index f103b43d4..6f2933fd6 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx @@ -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(list: T[], index: number, direction: -1 | 1): T[] { const target = index + direction; @@ -284,6 +285,8 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) { )} + + setSectionDrawer(null)} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/StaffRolesCard.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/StaffRolesCard.tsx new file mode 100644 index 000000000..3ae229549 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/StaffRolesCard.tsx @@ -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; + +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(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 ( + + +
+ {t('certReq.staff.title', 'Staff roles')} + + {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.', + )} + +
+ +
+ + {isLoading ? ( + + ) : roles.length === 0 ? ( + + {t('certReq.staff.empty', 'No staff roles — this licence type has no Staff step.')} + + ) : ( + + {roles.map((role) => ( + + +
+ + {localized(role.name) || role.roleKey} + + {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}` : '', + })} + + {role.requiresExperience && ( + + {t('certReq.staff.experience', 'experience')} + {role.minYearsExperience ? ` · ${role.minYearsExperience}y` : ''} + + )} + + + key: {role.roleKey} + {role.requiredEvidence.length > 0 && + ` · ${role.requiredEvidence + .map((e) => `${e.docKey}${e.mandatory ? '*' : ''}`) + .join(', ')}`} + +
+ + setEditing({ role })}> + + + setDeleteTarget(role)}> + + + +
+
+ ))} +
+ )} + + setEditing(null)} + role={editing?.role ?? null} + nextSortOrder={roles.length + 1} + onSave={handleSave} + saving={creating || updating} + /> + + setDeleteTarget(null)} + title={t('certReq.staff.delete', 'Delete staff role')} + size="sm" + > + + + {t('certReq.staff.deleteConfirm', 'Remove "{{name}}" from this licence type\'s Staff step?', { + name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.roleKey : '', + })} + + + + + + + +
+ ); +} + +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(emptyDraft(nextSortOrder)); + const [keyError, setKeyError] = useState(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) { + 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 ( + + {isNew ? t('certReq.staff.add', 'Add staff role') : t('certReq.staff.edit', 'Edit staff role')} + + } + > + + { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, roleKey: value })); + }} + /> + + setDraft((d) => ({ ...d, name: v }))} + /> + + + setDraft((d) => ({ ...d, minCount: typeof v === 'number' ? v : 0 }))} + description={t('certReq.staff.minCountHelp', '0 makes the role optional')} + /> + setDraft((d) => ({ ...d, maxCount: typeof v === 'number' ? v : null }))} + description={t('certReq.staff.maxCountHelp', 'Leave empty for no limit')} + /> + + + { + const { checked } = e.currentTarget; + setDraft((d) => ({ + ...d, + requiresExperience: checked, + minYearsExperience: checked ? d.minYearsExperience : null, + })); + }} + /> + + {draft.requiresExperience && ( + + setDraft((d) => ({ ...d, minYearsExperience: typeof v === 'number' ? v : null })) + } + /> + )} + + setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : 0 }))} + /> + + + + {t( + 'certReq.staff.evidenceHelp', + 'One upload slot per document, asked of every person registered in this role.', + )} + + + {draft.requiredEvidence.map((evidence, i) => ( + + + + patchEvidence(i, { docKey: e.currentTarget.value })} + style={{ flex: 1 }} + /> + patchEvidence(i, { label: v })} + style={{ flex: 2 }} + /> + + + patchEvidence(i, { mandatory: e.currentTarget.checked })} + /> + + + ))} + + + + + + + + + + ); +} diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 7538b644b..9eb6330be 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -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, void>({ + query: () => ({ url: '/staff-role-requirements' }), + providesTags: () => [listTag('StaffRoleRequirement')], + }), + + createStaffRoleRequirement: builder.mutation< + StaffRoleRequirement, + Partial & { 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 + >({ + query: ({ id, ...body }) => ({ + url: `/staff-role-requirements/${id}`, + method: 'PUT', + body, + }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('StaffRoleRequirement')]), + }), + + deleteStaffRoleRequirement: builder.mutation({ + 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, void>({ @@ -1341,6 +1379,10 @@ export const { useCreateDocumentRequirementMutation, useUpdateDocumentRequirementMutation, useDeleteDocumentRequirementMutation, + useGetStaffRoleRequirementsQuery, + useCreateStaffRoleRequirementMutation, + useUpdateStaffRoleRequirementMutation, + useDeleteStaffRoleRequirementMutation, useGetDepartmentsQuery, useGetActiveDepartmentsQuery, useCreateDepartmentMutation, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index fb0b16093..a1373d5f4 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -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 {