mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 19:30:58 +00:00
feat: add scope filtering for personal documents in configuration and API
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Drawer,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -13,7 +14,13 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
|
||||
import type { ApplicationKind, DocumentRequirement, FormSchemaPalette } from '@ema-platform/api';
|
||||
import {
|
||||
useLocalized,
|
||||
type ApplicationKind,
|
||||
type DocumentRequirement,
|
||||
type FormSchemaPalette,
|
||||
type LicenseType,
|
||||
} from '@ema-platform/api';
|
||||
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
|
||||
import type { ConditionTarget } from '../config/schema-paths';
|
||||
|
||||
@@ -25,6 +32,16 @@ const MIME_OPTIONS = [
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
/**
|
||||
* Which licences a personal document is asked for.
|
||||
*
|
||||
* Empty means every licence (stored as a row with no licence type); otherwise
|
||||
* one row per chosen type, all sharing the key. The applicant sees one slot
|
||||
* either way — the portal collapses the rows by key — and only if they have
|
||||
* declared operating as one of the types.
|
||||
*/
|
||||
export type PersonalScope = string[];
|
||||
|
||||
function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftRequirement {
|
||||
return {
|
||||
key: '',
|
||||
@@ -37,6 +54,7 @@ function emptyDraft(applicationKind: ApplicationKind, personal: boolean): DraftR
|
||||
maxSizeMb: 5,
|
||||
requiresValidityDates: false,
|
||||
allowMultiple: false,
|
||||
isPersonal: personal,
|
||||
maxFiles: personal ? 1 : null,
|
||||
sortOrder: 0,
|
||||
};
|
||||
@@ -61,23 +79,32 @@ export function DocumentRequirementEditorDrawer({
|
||||
conditionTargets,
|
||||
saving,
|
||||
personal = false,
|
||||
licenseTypes = [],
|
||||
scope = [],
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Null = adding a new requirement. */
|
||||
requirement: DocumentRequirement | null;
|
||||
defaultApplicationKind: ApplicationKind;
|
||||
onSave: (draft: DraftRequirement) => void;
|
||||
onSave: (draft: DraftRequirement, scope: PersonalScope) => void;
|
||||
palette: FormSchemaPalette | undefined;
|
||||
conditionTargets: ConditionTarget[];
|
||||
saving: boolean;
|
||||
/** Editing a personal document — one that applies to every licence. */
|
||||
/** Editing a personal document — one kept in the applicant's own vault. */
|
||||
personal?: boolean;
|
||||
/** Licence types offered as scope; only read when `personal`. */
|
||||
licenseTypes?: LicenseType[];
|
||||
/** The licence types this document is already scoped to. */
|
||||
scope?: PersonalScope;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [draft, setDraft] = useState<DraftRequirement>(
|
||||
emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
const [scopeIds, setScopeIds] = useState<PersonalScope>(scope);
|
||||
const [appliesToAll, setAppliesToAll] = useState(scope.length === 0);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const isNew = !requirement;
|
||||
|
||||
@@ -96,13 +123,18 @@ export function DocumentRequirementEditorDrawer({
|
||||
maxSizeMb: requirement.maxSizeMb,
|
||||
requiresValidityDates: requirement.requiresValidityDates,
|
||||
allowMultiple: requirement.allowMultiple,
|
||||
isPersonal: requirement.isPersonal ?? personal,
|
||||
maxFiles: requirement.maxFiles ?? null,
|
||||
sortOrder: requirement.sortOrder,
|
||||
}
|
||||
: emptyDraft(defaultApplicationKind, personal),
|
||||
);
|
||||
setScopeIds(scope);
|
||||
setAppliesToAll(scope.length === 0);
|
||||
setKeyError(null);
|
||||
}
|
||||
// `scope` is a fresh array each render; the opened flag is what gates this.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, requirement, defaultApplicationKind, personal]);
|
||||
|
||||
function save() {
|
||||
@@ -124,14 +156,22 @@ export function DocumentRequirementEditorDrawer({
|
||||
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
...draft,
|
||||
key: draft.key.trim(),
|
||||
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
|
||||
// `allowMultiple` predates `maxFiles` and nothing reads it any more; kept
|
||||
// in step so the two columns never contradict each other.
|
||||
allowMultiple: draft.maxFiles !== 1,
|
||||
});
|
||||
if (personal && !appliesToAll && scopeIds.length === 0) {
|
||||
setKeyError(t('certReq.doc.scopeRequired', 'Choose at least one licence type'));
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
{
|
||||
...draft,
|
||||
key: draft.key.trim(),
|
||||
conditionExpression: draft.mode === 'CONDITIONAL' ? draft.conditionExpression : undefined,
|
||||
isPersonal: personal,
|
||||
// `allowMultiple` predates `maxFiles` and nothing reads it any more;
|
||||
// kept in step so the two columns never contradict each other.
|
||||
allowMultiple: draft.maxFiles !== 1,
|
||||
},
|
||||
appliesToAll ? [] : scopeIds,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -167,6 +207,42 @@ export function DocumentRequirementEditorDrawer({
|
||||
onChange={(v) => setDraft((d) => ({ ...d, description: v }))}
|
||||
/>
|
||||
|
||||
{personal && (
|
||||
<Stack gap="xs">
|
||||
<Text fz="sm" fw={500}>
|
||||
{t('certReq.doc.scope', 'Applies to')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={appliesToAll ? 'all' : 'selected'}
|
||||
onChange={(v) => setAppliesToAll(v === 'all')}
|
||||
data={[
|
||||
{ value: 'all', label: t('certReq.doc.scopeAll', 'All licences') },
|
||||
{
|
||||
value: 'selected',
|
||||
label: t('certReq.doc.scopeSelected', 'Selected licence types'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{!appliesToAll && (
|
||||
<MultiSelect
|
||||
data={licenseTypes
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key }))}
|
||||
value={scopeIds}
|
||||
onChange={setScopeIds}
|
||||
searchable
|
||||
placeholder={t('certReq.doc.scopePlaceholder', 'Choose licence types')}
|
||||
description={t(
|
||||
'certReq.doc.scopeHelp',
|
||||
'Only applicants who declared one of these as a mode of operation are asked for it.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!personal && (
|
||||
<Select
|
||||
label={t('certReq.doc.applicationKind', 'Application kind')}
|
||||
|
||||
@@ -50,7 +50,12 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||
|
||||
const requirements = useMemo(
|
||||
() => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id),
|
||||
// Personal documents can also name a licence type — they are asked for in
|
||||
// the applicant's vault, not on this form, and are edited in Configuration.
|
||||
() =>
|
||||
(data?.items ?? []).filter(
|
||||
(r) => r.licenseTypeId === licenseType.id && !r.isPersonal,
|
||||
),
|
||||
[data, licenseType.id],
|
||||
);
|
||||
const conditionTargets = collectConditionTargets(licenseType.formSchema.sections);
|
||||
|
||||
@@ -18,22 +18,42 @@ import {
|
||||
useCreateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useGetDocumentRequirementsQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useLocalized,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
type DocumentRequirement,
|
||||
} from '@ema-platform/api';
|
||||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||||
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
|
||||
import {
|
||||
DocumentRequirementEditorDrawer,
|
||||
type PersonalScope,
|
||||
} from './DocumentRequirementEditorDrawer';
|
||||
|
||||
type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>;
|
||||
|
||||
/**
|
||||
* Documents every applicant keeps, whatever they apply for.
|
||||
* One personal document as an administrator thinks of it.
|
||||
*
|
||||
* These are the same `document_requirements` rows as the per-licence-type tab
|
||||
* below, minus the licence type: a row with none is not a slot on one
|
||||
* application form but a slot in the applicant's own document vault, which
|
||||
* the portal shows under My Documents. Kept out of the licence-type tab
|
||||
* deliberately — a document that belongs to everybody reads wrong filed under
|
||||
* whichever type happened to be selected.
|
||||
* The table stores a row per licence type, so "sea service book, for CoC and
|
||||
* endorsements" is two rows sharing a key. They are one document here — and
|
||||
* one slot in the applicant's vault — so the screen groups by key and the
|
||||
* scope is the set of licence types those rows name.
|
||||
*/
|
||||
interface PersonalDocumentGroup {
|
||||
key: string;
|
||||
rows: DocumentRequirement[];
|
||||
/** Empty when the document applies to every licence. */
|
||||
scope: PersonalScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents an applicant keeps in their own vault.
|
||||
*
|
||||
* Same `document_requirements` table as a licence type's upload slots, flagged
|
||||
* `isPersonal`: these are not asked for on an application form but held once,
|
||||
* under My Documents in the portal. A document can apply to every licence or
|
||||
* only to the modes of operation an applicant has declared — a sea service
|
||||
* book is worth asking a seafarer for and pointless for a freight forwarder.
|
||||
*/
|
||||
export function PersonalDocumentsCard() {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,39 +61,76 @@ export function PersonalDocumentsCard() {
|
||||
const run = useRequirementActions();
|
||||
|
||||
const { data } = useGetDocumentRequirementsQuery();
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation();
|
||||
const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation();
|
||||
const [deleteRequirement] = useDeleteDocumentRequirementMutation();
|
||||
|
||||
const [editing, setEditing] = useState<{ requirement: DocumentRequirement | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentRequirement | null>(null);
|
||||
const [editing, setEditing] = useState<{ group: PersonalDocumentGroup | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<PersonalDocumentGroup | null>(null);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(data?.items ?? [])
|
||||
.filter((r) => r.licenseTypeId === null)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[data],
|
||||
);
|
||||
const groups = useMemo<PersonalDocumentGroup[]>(() => {
|
||||
const byKey = new Map<string, DocumentRequirement[]>();
|
||||
for (const row of data?.items ?? []) {
|
||||
if (!row.isPersonal) continue;
|
||||
byKey.set(row.key, [...(byKey.get(row.key) ?? []), row]);
|
||||
}
|
||||
return [...byKey.entries()]
|
||||
.map(([key, rows]) => ({
|
||||
key,
|
||||
rows,
|
||||
// A single row with no licence type means "every licence"; the two
|
||||
// never coexist, because the editor writes one shape or the other.
|
||||
scope: rows
|
||||
.map((r) => r.licenseTypeId)
|
||||
.filter((id): id is string => id !== null),
|
||||
}))
|
||||
.sort((a, b) => a.rows[0].sortOrder - b.rows[0].sortOrder);
|
||||
}, [data]);
|
||||
|
||||
const typeName = (id: string) => {
|
||||
const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id);
|
||||
return found ? localized(found.name) || found.key : id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the group as the set of rows it now means.
|
||||
*
|
||||
* The scope is edited as a whole, so the diff is the honest way to apply it:
|
||||
* rows for licence types that were added get created, rows for types that
|
||||
* were dropped get deleted, and everything still in scope is updated. A
|
||||
* document moved to "all licences" collapses to a single row with none.
|
||||
*/
|
||||
async function handleSave(draft: DraftRequirement, scope: PersonalScope) {
|
||||
const existing = editing?.group?.rows ?? [];
|
||||
// `null` is a licence type here too — the one meaning "every licence".
|
||||
const wanted: (string | null)[] = scope.length ? scope : [null];
|
||||
|
||||
const ok = await run(async () => {
|
||||
const stale = existing.filter((row) => !wanted.includes(row.licenseTypeId));
|
||||
const kept = existing.filter((row) => wanted.includes(row.licenseTypeId));
|
||||
const added = wanted.filter(
|
||||
(id) => !existing.some((row) => row.licenseTypeId === id),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...kept.map((row) => updateRequirement({ id: row.id, ...draft }).unwrap()),
|
||||
...added.map((licenseTypeId) =>
|
||||
createRequirement({ ...draft, licenseTypeId }).unwrap(),
|
||||
),
|
||||
...stale.map((row) => deleteRequirement(row.id).unwrap()),
|
||||
]);
|
||||
}, editing?.group
|
||||
? t('certReq.doc.updated', 'Document requirement updated')
|
||||
: t('certReq.doc.created', 'Document requirement added'));
|
||||
|
||||
async function handleSave(draft: Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isActive'>) {
|
||||
const ok = await run(
|
||||
() =>
|
||||
editing?.requirement
|
||||
? updateRequirement({ id: editing.requirement.id, ...draft }).unwrap()
|
||||
: // No licenceTypeId at all: that absence is what makes it personal.
|
||||
createRequirement(draft).unwrap(),
|
||||
editing?.requirement
|
||||
? t('certReq.doc.updated', 'Document requirement updated')
|
||||
: t('certReq.doc.created', 'Document requirement added'),
|
||||
);
|
||||
if (ok) setEditing(null);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
const ok = await run(
|
||||
() => deleteRequirement(deleteTarget.id).unwrap(),
|
||||
() => Promise.all(deleteTarget.rows.map((row) => deleteRequirement(row.id).unwrap())),
|
||||
t('certReq.doc.deleted', 'Document requirement removed'),
|
||||
);
|
||||
if (ok) setDeleteTarget(null);
|
||||
@@ -82,12 +139,12 @@ export function PersonalDocumentsCard() {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb={4} wrap="nowrap">
|
||||
<Title order={5}>{t('certReq.personal.title', 'Documents required for every licence')}</Title>
|
||||
<Title order={5}>{t('certReq.personal.title', 'Personal documents')}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={13} />}
|
||||
onClick={() => setEditing({ requirement: null })}
|
||||
onClick={() => setEditing({ group: null })}
|
||||
>
|
||||
{t('certReq.personal.add', 'Add personal document')}
|
||||
</Button>
|
||||
@@ -95,62 +152,87 @@ export function PersonalDocumentsCard() {
|
||||
<Text fz="sm" c="dimmed" mb="sm">
|
||||
{t(
|
||||
'certReq.personal.subtitle',
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application.',
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
{groups.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||
{t('certReq.personal.empty', 'No personal documents configured yet.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{rows.map((req) => (
|
||||
<Card key={req.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(req.name) || req.key}
|
||||
{groups.map((group) => {
|
||||
const first = group.rows[0];
|
||||
return (
|
||||
<Card
|
||||
key={group.key}
|
||||
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(first.name) || group.key}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{first.maxFiles === null
|
||||
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
||||
: t('certReq.personal.fileCount', '{{count}} file', {
|
||||
count: first.maxFiles,
|
||||
})}
|
||||
</Badge>
|
||||
{group.scope.length === 0 ? (
|
||||
<Badge size="xs" variant="outline" color="blue">
|
||||
{t('certReq.doc.scopeAll', 'All licences')}
|
||||
</Badge>
|
||||
) : (
|
||||
group.scope.map((id) => (
|
||||
<Badge key={id} size="xs" variant="outline" color="grape">
|
||||
{typeName(id)}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
key: {group.key} · {first.maxSizeMb}MB ·{' '}
|
||||
{first.allowedMimeTypes.join(', ')}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{req.maxFiles === null
|
||||
? t('certReq.doc.maxFilesUnlimited', 'No limit')
|
||||
: t('certReq.personal.fileCount', '{{count}} file', { count: req.maxFiles })}
|
||||
</Badge>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => setEditing({ group })}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(group)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => setEditing({ requirement: req })}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => setDeleteTarget(req)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DocumentRequirementEditorDrawer
|
||||
opened={editing !== null}
|
||||
onClose={() => setEditing(null)}
|
||||
requirement={editing?.requirement ?? null}
|
||||
requirement={editing?.group?.rows[0] ?? null}
|
||||
defaultApplicationKind="NEW"
|
||||
onSave={handleSave}
|
||||
palette={undefined}
|
||||
conditionTargets={[]}
|
||||
saving={creating || updating}
|
||||
personal
|
||||
licenseTypes={licenseTypes?.items ?? []}
|
||||
scope={editing?.group?.scope ?? []}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -168,7 +250,9 @@ export function PersonalDocumentsCard() {
|
||||
</Alert>
|
||||
<Text fz="sm">
|
||||
{t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', {
|
||||
name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.key : '',
|
||||
name: deleteTarget
|
||||
? localized(deleteTarget.rows[0].name) || deleteTarget.key
|
||||
: '',
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -1354,6 +1354,12 @@ export const am: Translations = {
|
||||
requiresValidity: "የቀን ገደብ ያስፈልጋል",
|
||||
allowMultiple: "ብዙ ስቀላዎችን ፍቀድ",
|
||||
multiple: "ብዙ",
|
||||
scope: "የሚመለከተው",
|
||||
scopeAll: "ሁሉም ፈቃዶች",
|
||||
scopeSelected: "የተመረጡ የፈቃድ አይነቶች",
|
||||
scopePlaceholder: "የፈቃድ አይነቶችን ይምረጡ",
|
||||
scopeHelp: "ከእነዚህ አንዱን የሥራ ዘርፍ አድርገው ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
|
||||
scopeRequired: "ቢያንስ አንድ የፈቃድ አይነት ይምረጡ",
|
||||
maxFiles: "የሚፈቀዱ ፋይሎች",
|
||||
maxFilesHelp: "ገደብ ከሌለ ባዶ ይተዉት። ፊትና ጀርባ ላለው ሰነድ 2 ይጠቀሙ።",
|
||||
maxFilesUnlimited: "ገደብ የለም",
|
||||
@@ -1361,9 +1367,9 @@ export const am: Translations = {
|
||||
when: "መቼ",
|
||||
},
|
||||
personal: {
|
||||
title: "ለሁሉም ፈቃዶች የሚያስፈልጉ ሰነዶች",
|
||||
title: "የግል ሰነዶች",
|
||||
subtitle:
|
||||
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች።",
|
||||
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች። ለተወሰኑ የፈቃድ አይነቶች ከወሰኑት፣ እነዚያን የሥራ ዘርፍ ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
|
||||
add: "የግል ሰነድ ጨምር",
|
||||
empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።",
|
||||
fileCount_one: "{{count}} ፋይል",
|
||||
|
||||
@@ -1359,6 +1359,13 @@ export const en = {
|
||||
requiresValidity: 'Requires validity dates',
|
||||
allowMultiple: 'Allow multiple uploads',
|
||||
multiple: 'multiple',
|
||||
scope: 'Applies to',
|
||||
scopeAll: 'All licences',
|
||||
scopeSelected: 'Selected licence types',
|
||||
scopePlaceholder: 'Choose licence types',
|
||||
scopeHelp:
|
||||
'Only applicants who declared one of these as a mode of operation are asked for it.',
|
||||
scopeRequired: 'Choose at least one licence type',
|
||||
maxFiles: 'Files accepted',
|
||||
maxFilesHelp: 'Leave empty for no limit. Use 2 for a document with a front and a back.',
|
||||
maxFilesUnlimited: 'No limit',
|
||||
@@ -1366,9 +1373,9 @@ export const en = {
|
||||
when: 'when',
|
||||
},
|
||||
personal: {
|
||||
title: 'Documents required for every licence',
|
||||
title: 'Personal documents',
|
||||
subtitle:
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application.',
|
||||
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
|
||||
add: 'Add personal document',
|
||||
empty: 'No personal documents configured yet.',
|
||||
fileCount_one: '{{count}} file',
|
||||
|
||||
@@ -77,6 +77,9 @@ const TAGS = [
|
||||
'DocumentRequirement',
|
||||
'Department',
|
||||
'Rank',
|
||||
// Owned by the personal-document slice; named here so declaring a mode of
|
||||
// operation can invalidate the vault, whose slots depend on it.
|
||||
'PersonalDocument',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
@@ -121,9 +124,17 @@ export const licensingApi = baseApi
|
||||
method: 'PUT',
|
||||
body,
|
||||
}),
|
||||
// The catalogue is filtered by this, so it has to refetch too.
|
||||
// The catalogue is filtered by this, so it has to refetch too — and so
|
||||
// is the personal document vault, which asks for the documents the
|
||||
// declared modes of operation need.
|
||||
invalidatesTags: (_r, error) =>
|
||||
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
|
||||
error
|
||||
? []
|
||||
: [
|
||||
listTag('OperatorType'),
|
||||
listTag('LicenseType'),
|
||||
listTag('PersonalDocument'),
|
||||
],
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -268,6 +268,12 @@ export interface DocumentRequirement {
|
||||
maxSizeMb: number;
|
||||
requiresValidityDates: boolean;
|
||||
allowMultiple: boolean;
|
||||
/**
|
||||
* True for a personal document — one the applicant keeps in their own vault
|
||||
* — rather than an upload slot on an application form. Orthogonal to
|
||||
* `licenseTypeId`, which still says which licences it applies to.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
|
||||
maxFiles: number | null;
|
||||
sortOrder: number;
|
||||
|
||||
Reference in New Issue
Block a user