Add rank selection and condition handling to certificate designer and requirements

This commit is contained in:
Nati
2026-08-24 07:39:24 +00:00
parent 1af7e90c21
commit 302c0242dd
7 changed files with 359 additions and 147 deletions

View File

@@ -1,13 +1,17 @@
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType } from '@ema-platform/api';
import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
import { groupedTypeOptions } from '../config/designer';
interface Props {
licenseTypes: LicenseType[];
typeId: string | null;
onTypeChange: (id: string | null) => void;
/** The selected licence type's rank ladder — empty for non-CoC/CoP types. */
ranks: Rank[];
rankId: string | null;
onRankChange: (id: string | null) => void;
validityMonths: number;
onValidityChange: (months: number) => void;
currentValidityMonths?: number | null;
@@ -22,6 +26,9 @@ export function DesignerToolbar({
licenseTypes,
typeId,
onTypeChange,
ranks,
rankId,
onRankChange,
validityMonths,
onValidityChange,
currentValidityMonths,
@@ -49,6 +56,23 @@ export function DesignerToolbar({
w={340}
/>
{/* CoC/CoP only — a rank can carry its own design (e.g. Master's
certificate differs from an OOW's). "Default" (null) is the design
every other rank under the type falls back to. */}
{ranks.length > 0 && (
<Select
label={t('designer.rank', 'Rank')}
description={t('designer.rankHint', 'Leave as Default to design for every rank')}
data={[
{ value: '', label: t('designer.rankDefault', 'Default (all ranks)') },
...ranks.map((r) => ({ value: r.id, label: localized(r.name) })),
]}
value={rankId ?? ''}
onChange={(value) => onRankChange(value || null)}
w={220}
/>
)}
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput

View File

@@ -23,9 +23,11 @@ import {
useArchiveLicenseTemplateMutation,
useCreateLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetActiveDepartmentsQuery,
useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetRankLadderQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
@@ -66,14 +68,19 @@ export function CertificateDesignerPage() {
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const [rankId, setRankId] = useState<string | null>(null);
const {
data: templates = [],
data: allTemplates = [],
isLoading,
isError,
error,
refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
// The list is per licence type; a rank-specific design and the type's
// default both come back, so the version list is scoped to whichever the
// toolbar has selected.
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
const { data: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery();
@@ -95,6 +102,20 @@ export function CertificateDesignerPage() {
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
// A rank ladder only exists for CoC/CoP — every other licence type designs
// one certificate for everyone who holds it.
const isRankScoped =
selectedType?.certificateCategory === 'COC' || selectedType?.certificateCategory === 'COP';
const { data: departments } = useGetActiveDepartmentsQuery(undefined, { skip: !isRankScoped });
const department = departments?.find((d) => d.code === selectedType?.stcwDepartment);
const { data: ranks = [] } = useGetRankLadderQuery(
{
departmentId: department?.id ?? '',
certificateCategory: (selectedType?.certificateCategory ?? 'COC') as 'COC' | 'COP',
},
{ skip: !isRankScoped || !department },
);
// Default to the first licence type so the page is never an empty shell.
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
@@ -104,6 +125,12 @@ export function CertificateDesignerPage() {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
// Switching licence type leaves a stale rank selected from the previous
// type's ladder — reset to the type's default design.
useEffect(() => {
setRankId(null);
}, [typeId]);
function startNewVersion() {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
@@ -130,6 +157,12 @@ export function CertificateDesignerPage() {
setTypeId(value);
draft.setSelectedId(null);
}}
ranks={ranks}
rankId={rankId}
onRankChange={(value) => {
setRankId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths}
onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths}
@@ -369,6 +402,7 @@ export function CertificateDesignerPage() {
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
rankId,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();