diff --git a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx index 5c19085fa..4ab644bc6 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/DesignerToolbar.tsx @@ -1,4 +1,4 @@ -import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; +import { Button, Group, Select, Stack, Text } from '@mantine/core'; import { IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api'; @@ -12,12 +12,11 @@ interface Props { ranks: Rank[]; rankId: string | null; onRankChange: (id: string | null) => void; + /** Shown for context only; edited on Certificate Requirements → Behaviour. */ validityMonths: number; - onValidityChange: (months: number) => void; - currentValidityMonths?: number | null; + /** Non-null when the type is configured in days rather than months. */ + validityDays?: number | null; canEdit: boolean; - savingValidity: boolean; - onSaveValidity: () => void; onNewVersion: () => void; } @@ -30,11 +29,8 @@ export function DesignerToolbar({ rankId, onRankChange, validityMonths, - onValidityChange, - currentValidityMonths, + validityDays, canEdit, - savingValidity, - onSaveValidity, onNewVersion, }: Props) { const { t } = useTranslation(); @@ -73,38 +69,31 @@ export function DesignerToolbar({ /> )} - {/* Validity lives beside the design because it is the other half of - what a certificate promises. */} - onValidityChange(Math.round(Number(value || 0) * 12))} - min={0.5} - max={20} - step={0.5} - decimalScale={1} - w={190} - disabled={!canEdit} - /> - - - - - + {/* Read-only here. Validity is one policy decision with the renewal + window and the expiry reminders, so it is edited in one place — + Certificate Requirements → Behaviour — rather than from two screens + behind two different permissions. Still shown, because a designer + laying out a certificate that prints an expiry needs to see the term + it promises. */} + {typeId && ( + + + {t('designer.validity', 'Valid for')} + + + + {validityDays != null + ? t('designer.validityDays', '{{count}} days', { count: validityDays }) + : t('designer.validityMonths', '{{count}} months', { + count: validityMonths, + })} + + + {t('designer.validityEditedOn', '— set on Certificate Requirements → Behaviour')} + + + + )}
diff --git a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx index f9401f3e4..b46d68689 100644 --- a/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/pages/CertificateDesignerPage.tsx @@ -29,7 +29,6 @@ import { useGetRanksQuery, useGetTemplateVariablesQuery, usePublishLicenseTemplateMutation, - useUpdateLicenseValidityMutation, useUpdateLicenseTemplateMutation, } from '@ema-platform/api'; import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui'; @@ -88,7 +87,6 @@ export function CertificateDesignerPage() { const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation(); const [archiveTemplate] = useArchiveLicenseTemplateMutation(); const [deleteTemplate] = useDeleteLicenseTemplateMutation(); - const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation(); const draft = useTemplateDraft(templates); const run = useDesignerActions(); @@ -96,7 +94,6 @@ export function CertificateDesignerPage() { const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); - const [validityMonths, setValidityMonths] = useState(12); const [mode, setMode] = useState<'canvas' | 'source'>('canvas'); const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); @@ -127,10 +124,6 @@ export function CertificateDesignerPage() { if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id); }, [licenseTypes, typeId]); - useEffect(() => { - 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(() => { @@ -169,17 +162,9 @@ export function CertificateDesignerPage() { setRankId(value); draft.setSelectedId(null); }} - validityMonths={validityMonths} - onValidityChange={setValidityMonths} - currentValidityMonths={selectedType?.validityMonths} + validityMonths={selectedType?.validityMonths ?? 12} + validityDays={selectedType?.validityDays ?? null} canEdit={canEdit} - savingValidity={savingValidity} - onSaveValidity={() => - run( - () => updateValidity({ id: typeId as string, validityMonths }).unwrap(), - t('designer.validitySaved', 'Validity updated'), - ) - } onNewVersion={startNewVersion} /> diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx new file mode 100644 index 000000000..cab0bf4c4 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/BehaviorTab.tsx @@ -0,0 +1,550 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + Button, + Group, + MultiSelect, + NumberInput, + Paper, + Select, + Stack, + Switch, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth'; +import { + useUpdateLicenseBehaviorMutation, + type CertificateCategory, + type CompletionEffect, + type LicenseType, + type ServiceKind, + type WorkflowProfile, +} from '@ema-platform/api'; +import { useRequirementActions } from '../hooks/useRequirementActions'; + +/** The shape the form edits — every field the behaviour endpoint accepts. */ +interface Draft { + workflowProfile: WorkflowProfile; + serviceKind: ServiceKind; + completionEffect: CompletionEffect | null; + certificateCategory: CertificateCategory | null; + requiresExamination: boolean; + inspectionRequired: boolean; + issuesCertificate: boolean; + renewalEnabled: boolean; + requiresSeafarerRegistration: boolean; + requiresValidMedical: boolean; + minSeaTimeDays: number | null; + capitalThreshold: number | null; + validityMonths: number; + validityDays: number | null; + renewalWindowDays: number; + expiryReminderDays: number[]; + requiresOperatorMode: boolean; + allowMultipleOpenDrafts: boolean; + requiresIssuanceScheduling: boolean; + uniqueFormKeyPath: string | null; + slaHours: number | null; +} + +/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */ +const REMINDER_OFFSETS = ['90', '60', '30', '14', '7']; + +function toDraft(licenseType: LicenseType): Draft { + return { + workflowProfile: licenseType.workflowProfile ?? 'STANDARD', + serviceKind: licenseType.serviceKind ?? 'LICENSE', + completionEffect: licenseType.completionEffect ?? null, + certificateCategory: licenseType.certificateCategory ?? null, + requiresExamination: licenseType.requiresExamination ?? false, + inspectionRequired: licenseType.inspectionRequired ?? true, + issuesCertificate: licenseType.issuesCertificate ?? true, + renewalEnabled: licenseType.renewalEnabled ?? true, + requiresSeafarerRegistration: + licenseType.requiresSeafarerRegistration ?? false, + requiresValidMedical: licenseType.requiresValidMedical ?? false, + minSeaTimeDays: licenseType.minSeaTimeDays ?? null, + capitalThreshold: + licenseType.capitalThreshold == null + ? null + : Number(licenseType.capitalThreshold), + validityMonths: licenseType.validityMonths ?? 12, + validityDays: licenseType.validityDays ?? null, + renewalWindowDays: licenseType.renewalWindowDays ?? 60, + expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7], + requiresOperatorMode: licenseType.requiresOperatorMode ?? true, + allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false, + requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false, + uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null, + slaHours: licenseType.slaHours ?? null, + }; +} + +/** + * How one licence type behaves: the course it runs, who may apply, when it + * renews and what rules the applicant meets. + * + * These were seed-only until now — changing an SLA target or a renewal window + * meant editing a file and redeploying. They are read live off the licence + * type rather than snapshotted onto applications, so a change here applies to + * files already in the queue as well as new ones. For the three settings that + * decide an application's course the server refuses the change outright while + * anything is still awaiting a decision, rather than stranding it. + */ +export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) { + const { t } = useTranslation(); + const run = useRequirementActions(); + const { can } = usePermissions(); + const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]); + + const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation(); + const [draft, setDraft] = useState(() => toDraft(licenseType)); + const [dirty, setDirty] = useState(false); + + // Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates + // the licence-type list, and the refetch that follows must not overwrite an + // edit the administrator is still working on. + useEffect(() => { + setDraft(toDraft(licenseType)); + setDirty(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [licenseType.id]); + + function set(key: K, value: Draft[K]) { + setDraft((current) => ({ ...current, [key]: value })); + setDirty(true); + } + + async function onSave() { + const ok = await run( + () => save({ id: licenseType.id, ...draft }).unwrap(), + t('certReq.behavior.saved', 'Configuration saved.'), + ); + if (ok) setDirty(false); + } + + return ( + +
+ } + > + + {t( + 'certReq.behavior.workflowWarning', + 'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.', + )} + + + + set('completionEffect', (v as CompletionEffect) ?? null)} + placeholder={t('certReq.behavior.noEffect', 'No side effect')} + clearable + disabled={!canEdit} + /> + + set('requiresExamination', e.currentTarget.checked)} + label={t('certReq.behavior.requiresExamination', 'Requires an examination')} + description={t( + 'certReq.behavior.requiresExaminationHint', + 'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.', + )} + disabled={!canEdit} + /> + + set('issuesCertificate', e.currentTarget.checked)} + label={t('certReq.behavior.issuesCertificate', 'Issues a certificate')} + description={t( + 'certReq.behavior.issuesCertificateHint', + 'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.', + )} + disabled={!canEdit} + /> + + ({ + value: v, + label: v, + }))} + value={draft.certificateCategory} + onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)} + placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')} + clearable + disabled={!canEdit} + /> +
+ +
+ {/* Amount + unit rather than a years box that silently divides by 12: + the seed says `validityMonths: 12` and this now says "12 Months", + so the two read the same. Months advance the calendar (issued on + the 31st, expires on the 31st); days are for terms shorter than a + month can express. */} + + { + const next = typeof v === 'number' ? v : 0; + if (!next) return; + if (draft.validityDays !== null) set('validityDays', next); + else set('validityMonths', next); + }} + // Matches the server's ranges, so the box cannot offer a value the + // save would reject: 1–3650 days, or 6–240 months. + min={draft.validityDays !== null ? 1 : 6} + max={draft.validityDays !== null ? 3650 : 240} + allowNegative={false} + disabled={!canEdit} + flex={1} + /> + v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))} - allowDeselect={false} - disabled={!isNew} - description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined} - /> + {personal && ( + + + {t('certReq.doc.scope', 'Applies to')} + + setAppliesToAll(v === 'all')} + data={[ + { value: 'all', label: t('certReq.doc.scopeAll', 'All licences') }, + { + value: 'selected', + label: t('certReq.doc.scopeSelected', 'Selected licence types'), + }, + ]} + /> + {!appliesToAll && ( + 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.', + )} + /> + )} + + )} - v && setDraft((d) => ({ ...d, applicationKind: v as ApplicationKind }))} + allowDeselect={false} + disabled={!isNew} + description={!isNew ? t('certReq.doc.kindLocked', 'Application kind cannot change once created') : undefined} + /> + )} - {draft.mode === 'CONDITIONAL' && ( + {!personal && ( + a.sortOrder - b.sortOrder) + .map((lt) => ({ value: lt.id, label: localized(lt.name) || lt.key })), + ]} + value={licenseTypeFilter} + onChange={(value) => { + setLicenseTypeFilter(value); + // A narrower list can be shorter than the page you were on. + setPageIndex(0); + }} + searchable + clearable + w={240} + /> + {isFiltered && ( + + )} + + + + + + setEditing(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 ?? []} + /> + + setDeleteTarget(null)} + title={t('certReq.doc.delete', 'Delete document requirement')} + size="sm" + > + + + {t( + 'certReq.personal.deleteWarning', + 'The slot disappears from every applicant’s My Documents. Files already uploaded are kept, but nobody can reach them.', + )} + + + {t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', { + name: deleteTarget + ? localized(deleteTarget.rows[0].name) || deleteTarget.key + : '', + })} + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx index 6110b8358..5bdd062f7 100644 --- a/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx +++ b/apps/backoffice/src/app/features/certificate-requirements/components/SectionEditorDrawer.tsx @@ -72,7 +72,10 @@ export function SectionEditorDrawer({ ? t('certReq.section.keyHelp', 'Letters, numbers and underscores only') : t('certReq.section.keyLocked', 'Key cannot change once created') } - onChange={(e) => setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, key: value })); + }} /> setDraft((d) => ({ ...d, group: e.currentTarget.value || undefined }))} + onChange={(e) => { + const { value } = e.currentTarget; + setDraft((d) => ({ ...d, group: value || undefined })); + }} /> }> {t('certReq.tabDocuments', 'Document requirements')} + }> + {t('certReq.tabBehavior', 'Behaviour')} + @@ -87,6 +97,10 @@ export function CertificateRequirementsPage() { + + + + )} diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx index a28c98db2..5fe270643 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx @@ -24,6 +24,7 @@ import { IconHash, IconInfoCircle, IconAnchor, + IconId, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { @@ -35,6 +36,9 @@ import { PageLoader, } from "@ema-platform/ui"; import { LocationPage } from "../../../location/pages/LocationPage"; +// Lives with the document-requirement editor it reuses; shown here because a +// document required for every licence is configuration, not one type's form. +import { PersonalDocumentsCard } from "../../../certificate-requirements/components/PersonalDocumentsCard"; import { CertificationPage } from "../../../certification/pages/CertificationPage"; import { NumberFormatTab } from "../../components/NumberFormatTab"; import { RankDepartmentTab } from "./RankDepartmentTab"; @@ -406,6 +410,9 @@ export function ConfigurationPage() { }> {t("configuration.ranksTab", "Ranks & Departments")} + }> + {t("configuration.personalDocumentsTab", "Personal Documents")} + @@ -427,6 +434,10 @@ export function ConfigurationPage() { + + + + ); diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx index adaca4a96..03ce1673e 100644 --- a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx +++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx @@ -179,9 +179,25 @@ function FeeEditModal({ // are real configuration states, not blank fields. const [chargeable, setChargeable] = useState(true); const [sameAsNew, setSameAsNew] = useState(true); + // The examined-certificate stages. Held the same way — null means "this stage + // is not charged", which is different from a blank box. + const [eligibilityFee, setEligibilityFee] = useState(''); + const [examinationFee, setExaminationFee] = useState(''); + const [certificateFee, setCertificateFee] = useState(''); + + const examined = licenseType?.requiresExamination ?? false; useEffect(() => { if (!licenseType) return; + setEligibilityFee( + licenseType.feeEligibility == null ? '' : Number(licenseType.feeEligibility), + ); + setExaminationFee( + licenseType.feeExamination == null ? '' : Number(licenseType.feeExamination), + ); + setCertificateFee( + licenseType.feeCertificate == null ? '' : Number(licenseType.feeCertificate), + ); setChargeable(licenseType.feeNewApplication !== null); setNewFee( licenseType.feeNewApplication === null @@ -221,6 +237,14 @@ function FeeEditModal({ feeNewApplication: chargeable ? Number(newFee) : null, feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null, feeCurrency: currency.trim() || 'ETB', + // Only sent for examined types; otherwise the columns stay untouched. + ...(examined + ? { + feeEligibility: eligibilityFee === '' ? null : Number(eligibilityFee), + feeExamination: examinationFee === '' ? null : Number(examinationFee), + feeCertificate: certificateFee === '' ? null : Number(certificateFee), + } + : {}), }).unwrap(); notify.success( t('paymentConfig.modal.updated', { @@ -325,6 +349,68 @@ function FeeEditModal({ )} + {examined && ( + <> + } + > + + {t( + 'paymentConfig.modal.examinedNotice', + 'This certificate is earned by examination, so it is charged in three stages. Unlike the fees above, these are not fixed at approval — a change applies to candidates already part-way through. Clearing one while candidates are waiting to pay it will be refused.', + )} + + + + setEligibilityFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setExaminationFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + setCertificateFee(v === '' ? '' : Number(v))} + min={0} + max={9_999_999_999} + thousandSeparator="," + allowNegative={false} + decimalScale={2} + /> + + )} + - + {([ + { key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const }, + { key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const }, + ]).map(({ key, label, variant }) => { + const hasDraft = draftTypeKeys.has(key); + return ( + + ); + })} @@ -372,8 +402,21 @@ export function CertificatesPage() { style={{ cursor: 'pointer' }} onClick={() => navigate(`/applications/${app.applicationId}`)} > - Details + {app.status === 'DRAFT' ? 'Continue' : 'Details'} + {/* Drafts only: once submitted the filing is a record, + and withdrawing it is the officer's call. */} + {app.status === 'DRAFT' && ( + + )} @@ -419,6 +462,16 @@ export function CertificatesPage() { )} + setDiscardTarget(null)} + onConfirm={handleDiscard} + loading={discarding} + title="Discard draft" + message={`Delete draft ${discardTarget?.id ?? ''}? Anything filled in so far is lost.`} + confirmLabel="Discard" + /> + setPreviewUrl(null)} diff --git a/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx b/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx new file mode 100644 index 000000000..5634a977f --- /dev/null +++ b/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx @@ -0,0 +1,378 @@ +import { useRef, useState } from 'react'; +import { + ActionIcon, + Alert, + Badge, + Button, + Card, + Divider, + FileButton, + Group, + Loader, + Modal, + Progress, + SimpleGrid, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { + IconAlertTriangle, + IconPaperclip, + IconRefresh, + IconTrash, + IconUpload, +} from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { ModalFooter } from '@ema-platform/ui'; +import { useLocalized } from '@ema-platform/api'; +import { + replacePersonalDocumentFile, + uploadPersonalDocumentFile, + useDeletePersonalDocumentFileMutation, + useGetMyPersonalDocumentsQuery, + type AttachmentFile, + type PersonalDocumentError, + type PersonalDocumentSlot, + type PersonalDocumentUploadResult, +} from '@ema-platform/api'; +import { PORTAL_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; + +/** + * A refused delete comes back through RTK, which nests the server's payload + * under `data`. Uploads report theirs directly — see the XHR helper. + */ +function errorBody(err: unknown): PersonalDocumentError { + const payload = (err as { data?: { message?: unknown } })?.data?.message; + return typeof payload === 'object' && payload !== null + ? (payload as PersonalDocumentError) + : { message: typeof payload === 'string' ? payload : undefined }; +} + +/** + * The applicant's own document vault. + * + * Slots are configuration, not code: the backoffice decides which documents + * everyone keeps and how many files each holds, so labels, accepted types and + * limits all arrive with the data. The upload button knows it is full for the + * same reason the server refuses a third file. + */ +export function PersonalDocumentSlots({ + onPreview, +}: { + onPreview: (preview: { url: string; title: string; mimeType?: string | null }) => void; +}) { + const { t } = useTranslation(); + const localized = useLocalized(); + + const { data, isLoading, refetch } = useGetMyPersonalDocumentsQuery(); + const [deleteFile] = useDeletePersonalDocumentFileMutation(); + + const [busy, setBusy] = useState(null); + // Percent for the upload in flight. A video is minutes of waiting, so the + // bar is the difference between waiting and reloading the page. + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + // Mantine's FileButton clears its input through a ref object, and there is + // one input per slot and per file, so the objects are kept by key. + const resetRefs = useRef void) | null }>>({}); + function resetRef(key: string) { + resetRefs.current[key] ??= { current: null }; + return resetRefs.current[key] as { current: () => void }; + } + function clearInput(key: string) { + resetRefs.current[key]?.current?.(); + } + + /** + * Checked here as well as on the server so the common mistakes — a PDF where + * a photograph belongs, a 12 MB scan — never cost a round trip. + */ + function rejectFile(slot: PersonalDocumentSlot, file: File): string | null { + if (slot.allowedMimeTypes.length && !slot.allowedMimeTypes.includes(file.type)) { + return t('documents.personal.errors.unsupported_document_type', { + allowed: slot.allowedMimeTypes.join(', '), + }); + } + if (file.size > slot.maxSizeMb * 1024 * 1024) { + return t('documents.personal.errors.document_too_large', { + maxBytes: slot.maxSizeMb * 1024 * 1024, + }); + } + return null; + } + + function describe(body: PersonalDocumentError): string { + return t(`documents.personal.errors.${body.message ?? 'unknown'}`, { + ...body, + defaultValue: t('documents.personal.errors.unknown'), + }); + } + + /** Deletes, which still go through RTK and refetch themselves. */ + async function run(busyKey: string, action: () => Promise) { + setBusy(busyKey); + setError(null); + try { + await action(); + } catch (err) { + setError(describe(errorBody(err))); + } finally { + setBusy(null); + clearInput(busyKey); + } + } + + /** + * Uploads, which report progress and so bypass RTK — the vault is refetched + * by hand once the file has landed. + */ + async function send( + busyKey: string, + action: (onProgress: (percent: number) => void) => Promise, + ) { + setBusy(busyKey); + setProgress(0); + setError(null); + const result = await action(setProgress); + if (result.ok) await refetch(); + else setError(describe(result.error)); + setBusy(null); + setProgress(null); + clearInput(busyKey); + } + + function handleUpload(slot: PersonalDocumentSlot, file: File | null) { + if (!file) return; + const rejected = rejectFile(slot, file); + if (rejected) { + setError(rejected); + clearInput(slot.key); + return; + } + return send(slot.key, (onProgress) => + uploadPersonalDocumentFile({ documentKey: slot.key, file, onProgress }), + ); + } + + function handleReplace(slot: PersonalDocumentSlot, fileId: string, file: File | null) { + if (!file) return; + const rejected = rejectFile(slot, file); + if (rejected) { + setError(rejected); + clearInput(fileId); + return; + } + return send(fileId, (onProgress) => + replacePersonalDocumentFile({ fileId, file, onProgress }), + ); + } + + async function confirmDelete() { + if (!deleteTarget) return; + await run(deleteTarget.id, () => deleteFile(deleteTarget.id).unwrap()); + setDeleteTarget(null); + } + + /** The bar belongs to the card whose slot, or whose file, is uploading. */ + function isThisSlot(slot: PersonalDocumentSlot, busyKey: string) { + return busyKey === slot.key || slot.files.some((f) => f.id === busyKey); + } + + if (isLoading) return ; + + const slots = data?.slots ?? []; + if (slots.length === 0) { + return ( + + {t('documents.personal.empty')} + + ); + } + + return ( + + + {t('documents.personal.description')} + + + {error && ( + } onClose={() => setError(null)} withCloseButton> + {error} + + )} + + + {slots.map((slot) => { + const full = slot.maxFiles !== null && slot.files.length >= slot.maxFiles; + return ( + + +
+ + {localized(slot.name)} + + {slot.description && ( + + {localized(slot.description)} + + )} +
+ + {slot.maxFiles === null + ? t('documents.personal.fileCountUnlimited', { count: slot.files.length }) + : t('documents.personal.fileCount', { + count: slot.files.length, + max: slot.maxFiles, + })} + +
+ + + + {slot.files.length === 0 ? ( + + {t('documents.files.none')} + + ) : ( + + {slot.files.map((file) => ( + + + + file.url && + onPreview({ + url: file.url, + title: file.originalName, + mimeType: file.mimeType, + }) + } + > + {file.originalName} + + + + handleReplace(slot, file.id, picked)} + accept={slot.allowedMimeTypes.join(',')} + > + {(props) => ( + + + + + + )} + + + setDeleteTarget(file)} + > + + + + + + + ))} + + )} + + {busy !== null && progress !== null && isThisSlot(slot, busy) && ( + + + + {t('documents.personal.uploading', { percent: progress })} + + + )} + + + +
+ handleUpload(slot, picked)} + accept={slot.allowedMimeTypes.join(',')} + > + {(props) => ( + + )} + +
+
+
+
+ ); + })} +
+ + setDeleteTarget(null)} + title={t('documents.personal.confirmDelete.title')} + size="sm" + centered + > + + + {t('documents.personal.confirmDelete.body', { + name: deleteTarget?.originalName ?? '', + })} + + + + + + + +
+ ); +} diff --git a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx index 172419552..efef94840 100644 --- a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx +++ b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx @@ -1,6 +1,54 @@ -import { Container } from '@mantine/core'; -import { FeatureUnavailable } from '@ema-platform/ui'; +import { useState } from 'react'; +import { + Anchor, + Badge, + Button, + Card, + Container, + Divider, + Group, + Loader, + SimpleGrid, + Stack, + Tabs, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconAnchor, + IconCertificate, + IconEye, + IconHeartbeat, + IconIdBadge2, + IconPaperclip, +} from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; +import { useDateDisplayer } from '@ema-platform/shared'; +import { FilePreviewModal, notify } from '@ema-platform/ui'; +import { + extractErrorMessage, + useGetAttachmentsQuery, + useGetCertificateUrlMutation, + useGetMyLicensesQuery, + useGetMyMedicalCertificatesQuery, + useGetMySeaServiceRecordsQuery, + useGetMySeafarerDocumentsQuery, + useLazyGetMySeafarerDocumentDownloadQuery, + type SeafarerDocument, + type SeafarerRecordStatus, +} from '@ema-platform/api'; +import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard'; +import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots'; + +/** What the viewer needs: the link, a caption, and how to render it. */ +type Preview = { url: string; title: string; mimeType?: string | null }; + +const RECORD_STATUS_COLOR: Record = { + SUBMITTED: 'blue', + VERIFIED: 'teal', + REJECTED: 'red', +}; /** * Placeholder until this feature has a backend. @@ -8,14 +56,403 @@ import { useTranslation } from 'react-i18next'; * This page previously rendered invented figures/records that were * indistinguishable from real ones. */ +function RecordFiles({ + ownerType, + ownerId, + onPreview, +}: { + ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE' | 'SEAFARER_REGISTRATION'; + ownerId: string; + onPreview: (preview: Preview) => void; +}) { + const { t } = useTranslation(); + const { data, isLoading } = useGetAttachmentsQuery({ ownerType, ownerId }); + const files = (data ?? []).flatMap((a) => a.files); + + if (isLoading) return ; + if (files.length === 0) + return ( + + {t('documents.files.none')} + + ); + + return ( + + {files.map((file) => ( + + + {file.url ? ( + + onPreview({ + url: file.url as string, + title: file.originalName, + mimeType: file.mimeType, + }) + } + > + {file.originalName} + + ) : ( + {file.originalName} + )} + + ))} + + ); +} + +function FieldRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +/** Seaman Book / BTC — issued on their own workflow, not as licences. */ +function IssuedDocumentCard({ + document, + onPreview, +}: { + document: SeafarerDocument; + onPreview: (preview: Preview) => void; +}) { + const { t } = useTranslation(); + const showDate = useDateDisplayer(); + const [download, { isFetching }] = useLazyGetMySeafarerDocumentDownloadQuery(); + const issued = document.status === 'ISSUED'; + + async function open() { + try { + const { url } = await download(document.id).unwrap(); + onPreview({ url, title: t(`documents.kind.${document.kind}`) }); + } catch (err) { + notify.error(extractErrorMessage(err), t('documents.openFailed')); + } + } + + return ( + + + + + + +
+ + {t(`documents.kind.${document.kind}`)} + + + {document.documentNumber ?? document.requestNumber} + +
+
+ + {t(`documents.documentStatus.${document.status}`, { defaultValue: document.status })} + +
+ + + + + {document.issueDate && ( + + )} + {document.expiryDate && ( + + )} + + + +
+ ); +} + export function DocumentVaultPage() { const { t } = useTranslation(); + const showDate = useDateDisplayer(); + const [preview, setPreview] = useState(null); + + const { data: licences, isLoading: loadingLicences } = useGetMyLicensesQuery(); + const { data: issuedDocuments } = useGetMySeafarerDocumentsQuery(); + const { data: medicals, isLoading: loadingMedicals } = useGetMyMedicalCertificatesQuery(); + const { data: seaService, isLoading: loadingSeaService } = useGetMySeaServiceRecordsQuery(); + + const [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation(); + const { renewLicense, isRenewing } = useRenewLicense(); + + async function openCertificate(licenseId: string) { + try { + const { url } = await getCertificateUrl(licenseId).unwrap(); + setPreview({ url, title: t('licensing.card.downloadCertificate') }); + } catch (err) { + notify.error(extractErrorMessage(err), t('documents.openFailed')); + } + } + + const licenceItems = licences?.items ?? []; + const issued = [issuedDocuments?.seamanBook, issuedDocuments?.btc].filter( + (d): d is SeafarerDocument => Boolean(d), + ); return ( - +
+ {t('documents.title')} + + {t('documents.subtitle')} + +
+ + + + }> + {t('documents.tabs.license')} + + }> + {t('documents.tabs.medical')} + + }> + {t('documents.tabs.seaService')} + + }> + {t('documents.tabs.personal')} + + + + {/* ── Licences and EMA-issued documents ───────────────────────── */} + + + {issued.length > 0 && ( +
+ + {t('documents.issuedTitle')} + + + {issued.map((document) => ( + + ))} + +
+ )} + +
+ + {t('documents.licensesTitle')} + + {loadingLicences ? ( + + ) : licenceItems.length === 0 ? ( + + {t('documents.empty.licenses')} + + ) : ( + // Two per row, not three: licence type names run long + // ("Multimodal Transport Operator License") and a third + // column truncates them. + + {licenceItems.map((licence) => ( + openCertificate(licence.id)} + onRenew={() => renewLicense(licence)} + /> + ))} + + )} +
+
+
+ + {/* ── Medical certificates ────────────────────────────────────── */} + + {loadingMedicals ? ( + + ) : (medicals ?? []).length === 0 ? ( + + {t('documents.empty.medical')} + + ) : ( + + {(medicals ?? []).map((record) => ( + + + + + + +
+ + {record.issuerName} + + {record.certificateNumber && ( + + {t('seaRecords.columns.certNumber', { + number: record.certificateNumber, + })} + + )} +
+
+ + {t(`seaRecords.columns.recordStatus.${record.status}`, { + defaultValue: record.status, + })} + +
+ + + + + + + + + + + +
+ ))} +
+ )} +
+ + {/* ── Sea service records ─────────────────────────────────────── */} + + {loadingSeaService ? ( + + ) : (seaService ?? []).length === 0 ? ( + + {t('documents.empty.seaService')} + + ) : ( + + {(seaService ?? []).map((record) => ( + + + + + + +
+ + {record.vesselName} + + {record.imoNumber && ( + + {t('seaRecords.columns.imo', { number: record.imoNumber })} + + )} +
+
+ + {t(`seaRecords.columns.recordStatus.${record.status}`, { + defaultValue: record.status, + })} + +
+ + + + + + + + + + + +
+ ))} +
+ )} +
+ + {/* ── The applicant's own document vault ──────────────────────── */} + + {/* Owned by the profile, not by a registration or an application: + the slots are configured in the backoffice and the files travel + with the person. */} + + +
+ + + setPreview(null)} + url={preview?.url ?? ''} + title={preview?.title} + mimeType={preview?.mimeType} + labels={{ + unsupported: t('documents.preview.unsupported'), + openInNewTab: t('documents.preview.openInNewTab'), + close: t('documents.preview.close'), + }} />
); diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx index 523a8c6ba..eda1deb92 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/actions.tsx @@ -1,5 +1,5 @@ import { Button, Group } from '@mantine/core'; -import { IconDownload } from '@tabler/icons-react'; +import { IconDownload, IconTrash } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import type { LicenseApplication } from '@ema-platform/api'; @@ -32,6 +32,7 @@ export function applicationActionsColumn( onPay: (app: LicenseApplication) => void; onRetakeExam: (app: LicenseApplication) => void; onOpen: (app: LicenseApplication) => void; + onDiscard: (app: LicenseApplication) => void; }, ): AdvancedColumn { return { @@ -126,6 +127,19 @@ export function applicationActionsColumn( : t('applications.actions.view')} )} + {/* Drafts only: after submission the filing is a record an officer + may already be reading, so it is withdrawn, not deleted. */} + {app.status === 'DRAFT' && ( + + )} ); }, diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx index 59f73dfaa..d2f173c7e 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx @@ -29,7 +29,7 @@ import { IconSearch, IconX, } from '@tabler/icons-react'; -import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui'; +import { AdvancedTable, AmharicDatePicker, ConfirmModal, EmptyState, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { LicenseCatalogue } from '../../components/LicenseCatalogue'; import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard'; @@ -42,6 +42,7 @@ import { applicantOrCompanyName, extractErrorMessage, useBypassPaymentMutation, + useDiscardApplicationMutation, useGetCertificateUrlMutation, useGetMyApplicationsQuery, useGetMyLicensesQuery, @@ -102,6 +103,8 @@ export function MyApplicationsPage() { const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation(); const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation(); const [getCertificateUrl] = useGetCertificateUrlMutation(); + const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation(); + const [discardTarget, setDiscardTarget] = useState<{ id: string; label: string } | null>(null); const { renewLicense, isRenewing } = useRenewLicense(); const { reissueLicense, isReissuing } = useReissueLicense(); const [isDownloadingCert, setIsDownloadingCert] = useState(false); @@ -135,6 +138,26 @@ export function MyApplicationsPage() { } } + /** Throws away an unfinished draft, after the applicant confirms. */ + async function handleDiscard() { + if (!discardTarget) return; + try { + await discardApplication(discardTarget.id).unwrap(); + notifications.show({ + color: 'teal', + title: t('applications.actions.discarded'), + message: discardTarget.label, + }); + setDiscardTarget(null); + } catch (err) { + notifications.show({ + color: 'red', + title: t('applications.actions.discardFailed'), + message: extractErrorMessage(err), + }); + } + } + /** * Re-opens the examination fee for a failed candidate. * @@ -290,6 +313,8 @@ export function MyApplicationsPage() { onRetakeExam: (app) => retakeExamFee(app.id), onOpen: (app) => navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`), + onDiscard: (app) => + setDiscardTarget({ id: app.id, label: app.applicationNumber }), }), ]; @@ -511,6 +536,19 @@ export function MyApplicationsPage() { {tab === 'apply' && } + + setDiscardTarget(null)} + onConfirm={handleDiscard} + loading={discarding} + title={t('applications.actions.discard')} + message={t('applications.actions.discardConfirm', { + number: discardTarget?.label ?? '', + })} + confirmLabel={t('applications.actions.discard')} + cancelLabel={t('common.cancel')} + /> ); } diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 8ae1589c8..7e30e5697 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -229,6 +229,10 @@ export const am: Translations = { view: 'ይመልከቱ', bypass: 'ክፍያ ዝለል', renew: 'አድስ', + discard: 'አጥፋ', + discardConfirm: 'ረቂቅ {{number}} ይጥፋ? እስካሁን የተሞላው ሁሉ ይጠፋል።', + discarded: 'ረቂቁ ጠፍቷል', + discardFailed: 'ረቂቁን ማጥፋት አልተቻለም', }, notice: { paymentReceived: @@ -1385,17 +1389,41 @@ export const am: Translations = { files: { none: 'ምንም የተያያዘ ፋይል የለም።', }, + preview: { + unsupported: 'ይህ የፋይል አይነት እዚህ ሊታይ አይችልም። ለማውረድ በአዲስ ትር ይክፈቱት።', + openInNewTab: 'በአዲስ ትር ክፈት', + close: 'ዝጋ', + }, empty: { licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።', medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።', seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።', }, personal: { - description: 'ከመርከበኛ ምዝገባዎ ጋር ያስገቡት ሰነዶች።', - noRegistration: 'እስካሁን የመርከበኛ ምዝገባ ስለሌለዎት በመዝገብ ላይ የግል ሰነዶች የሉም።', - startRegistration: 'ወደ መርከበኛ ምዝገባ ይሂዱ', + description: 'የማንነትና የትምህርት ሰነዶችዎ። እዚህ አንድ ጊዜ ይስቀሉ፤ በመዝገብዎ ላይ ይቆያሉ።', + empty: 'እስካሁን የተዋቀረ የግል ሰነድ የለም።', uploaded: 'ተሰቅሏል', missing: 'አልተሰቀለም', + fileCount: '{{count}} ከ {{max}}', + fileCountUnlimited_one: '{{count}} ፋይል', + fileCountUnlimited_other: '{{count}} ፋይሎች', + slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።', + uploading: 'በመስቀል ላይ… {{percent}}%', + delete: 'ፋይል አስወግድ', + confirmDelete: { + title: 'ይህን ፋይል ያስወግዱ?', + body: '"{{name}}"ን ያስወግዱ? በኋላ እንደገና መስቀል ይችላሉ።', + confirm: 'አስወግድ', + }, + errors: { + unknown: 'ፋይሉ ሊቀመጥ አልቻለም። እንደገና ይሞክሩ።', + unknown_document_key: 'ይህ ሰነድ አሁን አይሰበሰብም።', + unsupported_document_type: 'ይህ የፋይል አይነት እዚህ አይፈቀድም። የተፈቀዱት፦ {{allowed}}።', + document_too_large: 'ፋይሉ በጣም ትልቅ ነው።', + document_file_required: 'የሚሰቀል ፋይል ይምረጡ።', + slot_full: 'ይህ ሰነድ ቀድሞውኑ {{maxFiles}} ፋይል(ሎች) ይዟል። በምትኩ አንዱን ይተኩ።', + document_file_not_found: 'ይህ ፋይል በመዝገብዎ ላይ የለም።', + }, }, }, }; diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index abd50a6b4..ab365d0c1 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -229,6 +229,11 @@ export const en = { view: 'View', bypass: 'Bypass payment', renew: 'Renew', + discard: 'Discard', + discardConfirm: + 'Delete draft {{number}}? Anything filled in so far is lost.', + discarded: 'Draft discarded', + discardFailed: 'Could not discard draft', }, notice: { paymentReceived: @@ -1389,18 +1394,43 @@ export const en = { files: { none: 'No files attached.', }, + preview: { + unsupported: + 'This file type cannot be shown here. Open it in a new tab to download it.', + openInNewTab: 'Open in a new tab', + close: 'Close', + }, empty: { licenses: 'No certificates or licences have been issued to you yet.', medical: 'No medical certificates on file yet.', seaService: 'No sea-service records on file yet.', }, personal: { - description: 'The documents you submitted with your seafarer registration.', - noRegistration: - 'You have no seafarer registration yet, so there are no personal documents on file.', - startRegistration: 'Go to seafarer registration', + description: + 'Your identity and education documents. Upload them once here and they stay on your record.', + empty: 'No personal documents are configured yet.', uploaded: 'Uploaded', missing: 'Not uploaded', + fileCount: '{{count}} of {{max}}', + fileCountUnlimited_one: '{{count}} file', + fileCountUnlimited_other: '{{count}} files', + slotFull: 'This document is complete. Replace or remove a file to change it.', + uploading: 'Uploading… {{percent}}%', + delete: 'Remove file', + confirmDelete: { + title: 'Remove this file?', + body: 'Remove "{{name}}"? You can upload it again afterwards.', + confirm: 'Remove', + }, + errors: { + unknown: 'The file could not be saved. Try again.', + unknown_document_key: 'This document is no longer being collected.', + unsupported_document_type: 'That file type is not accepted here. Allowed: {{allowed}}.', + document_too_large: 'That file is too large.', + document_file_required: 'Choose a file to upload.', + slot_full: 'This document already holds {{maxFiles}} file(s). Replace one instead.', + document_file_not_found: 'That file is no longer on your record.', + }, }, }, }; diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts index 8bf4d7a69..1e0de2921 100644 --- a/libs/api/src/index.ts +++ b/libs/api/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/features/location'; export * from './lib/features/seafarer'; export * from './lib/features/seafarer-registration'; export * from './lib/features/seafarer-document'; +export * from './lib/features/personal-document'; export * from './lib/features/biometric-enrollment'; export * from './lib/features/vessel'; export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; diff --git a/libs/api/src/lib/base-api/mock-base-query.ts b/libs/api/src/lib/base-api/mock-base-query.ts index 8f2244afc..6bfe5f4aa 100644 --- a/libs/api/src/lib/base-api/mock-base-query.ts +++ b/libs/api/src/lib/base-api/mock-base-query.ts @@ -172,6 +172,15 @@ const handlers: MockHandler[] = [ return detail ? clone(detail) : undefined; }, }, + { + method: 'DELETE', + pattern: /^\/license-applications\/([\w-]+)$/, + respond: (_req, match) => { + delete mockApplications[match[1]]; + delete mockApplicationDetails[match[1]]; + return { deleted: true }; + }, + }, { method: 'PATCH', pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/, diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 20112598d..80e1bb8e0 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -21,6 +21,8 @@ import type { LicenseTypeRequirements, OperatorType, AssignableOfficer, + CertificateCategory, + CompletionEffect, DocumentDecision, DocumentReview, EligibleExam, @@ -37,10 +39,14 @@ import type { RemarkTargetType, SavedQueueView, SchemaIssue, + ServiceKind, TemplateFieldPlacement, TemplateLogoPlacement, TemplatePageOptions, TemplateVariable, + PersonalDocumentFilter, + PersonalDocumentGroup, + WorkflowProfile, } from './licensing.types'; /** @@ -67,6 +73,16 @@ function serialiseQueueFilter( return params; } +/** Sends only the facets that are set; `search=` would match nothing. */ +function dropEmpty(filter: object): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(filter)) { + if (value === undefined || value === null || value === '' || value === false) continue; + params[key] = value; + } + return params; +} + const TAGS = [ 'LicenseType', 'OperatorType', @@ -83,6 +99,9 @@ const TAGS = [ 'PickupAppointment', '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; @@ -127,9 +146,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'), + ], }), /** @@ -159,6 +186,12 @@ export const licensingApi = baseApi feeNewApplication?: number | null; feeRenewal?: number | null; feeCurrency?: string; + // The examined-certificate stages. Unlike the two above, these are + // read live off the licence type rather than snapshotted, so the + // server refuses to clear one a candidate is currently waiting on. + feeEligibility?: number | null; + feeExamination?: number | null; + feeCertificate?: number | null; } >({ query: ({ id, ...body }) => ({ @@ -170,6 +203,53 @@ export const licensingApi = baseApi error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)], }), + /** + * How a licence type behaves: its workflow, eligibility gates, renewal + * policy and applicant rules — everything that used to be settable only + * by editing a seed file. + * + * Three of these (`workflowProfile`, `completionEffect`, + * `requiresExamination`) decide the course an application runs, and are + * read live rather than snapshotted. The server answers 409 + * `license_type_in_use` when changing one would strand applications that + * have not yet had their approval decision. + */ + updateLicenseBehavior: builder.mutation< + LicenseType, + { + id: string; + workflowProfile?: WorkflowProfile; + serviceKind?: ServiceKind; + completionEffect?: CompletionEffect | null; + certificateCategory?: CertificateCategory | null; + requiresExamination?: boolean; + inspectionRequired?: boolean; + issuesCertificate?: boolean; + renewalEnabled?: boolean; + requiresSeafarerRegistration?: boolean; + requiresValidMedical?: boolean; + minSeaTimeDays?: number | null; + validityMonths?: number; + validityDays?: number | null; + capitalThreshold?: number | null; + renewalWindowDays?: number; + expiryReminderDays?: number[]; + requiresOperatorMode?: boolean; + allowMultipleOpenDrafts?: boolean; + requiresIssuanceScheduling?: boolean; + uniqueFormKeyPath?: string | null; + slaHours?: number | null; + } + >({ + query: ({ id, ...body }) => ({ + url: `/license-types/${id}/behavior`, + method: 'PATCH', + body, + }), + invalidatesTags: (_r, error, { id }) => + error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)], + }), + /** Validity is edited beside the certificate design, not with the fees. */ updateLicenseValidity: builder.mutation< LicenseType, @@ -240,9 +320,30 @@ export const licensingApi = baseApi providesTags: () => [listTag('DocumentRequirement')], }), + /** + * Personal document slots, grouped by key and paged by the server. + * + * Its own endpoint rather than filtering `getDocumentRequirements` in the + * browser: one document can be configured against several licence types, + * so a page of rows would split a document in half and misreport its + * scope. The server groups first, then pages. + */ + getPersonalDocuments: builder.query< + Paginated, + PersonalDocumentFilter | void + >({ + query: (filter) => ({ + url: '/document-requirements/personal', + params: dropEmpty(filter ?? {}), + }), + providesTags: () => [listTag('DocumentRequirement')], + }), + createDocumentRequirement: builder.mutation< DocumentRequirement, - Partial & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] } + // No `licenseTypeId` means a personal document, required for every + // licence and served from the applicant's own vault. + Partial & { key: string; name: DocumentRequirement['name'] } >({ query: (body) => ({ url: '/document-requirements', method: 'POST', body }), invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]), @@ -343,6 +444,11 @@ export const licensingApi = baseApi invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]), }), + discardApplication: builder.mutation<{ deleted: boolean }, string>({ + query: (id) => ({ url: `/license-applications/${id}`, method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]), + }), + getMyApplications: builder.query, void>({ query: () => ({ url: '/license-applications/mine' }), providesTags: () => [listTag('LicenseApplication')], @@ -1196,10 +1302,12 @@ export const { useGetLicenseTypesQuery, useGetLicenseCategoriesQuery, useUpdateLicenseFeesMutation, + useUpdateLicenseBehaviorMutation, useUpdateFormSchemaMutation, useValidateFormSchemaMutation, useGetFormSchemaPaletteQuery, useGetDocumentRequirementsQuery, + useGetPersonalDocumentsQuery, useCreateDocumentRequirementMutation, useUpdateDocumentRequirementMutation, useDeleteDocumentRequirementMutation, @@ -1216,6 +1324,7 @@ export const { useUpdateLicenseValidityMutation, useGetLicenseTypeRequirementsQuery, useCreateApplicationMutation, + useDiscardApplicationMutation, useGetMyApplicationsQuery, useGetApplicationQuery, useInitiatePaymentMutation, diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index 0a2b91e32..9a74257bb 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -400,6 +400,15 @@ const ERROR_MESSAGES: Record = { inspection_not_passed: 'Approval requires a passed inspection. Schedule a re-inspection or request corrections.', license_type_inactive: 'This licence type is not currently accepting applications.', + + // Licence-type configuration guards. Each of these refuses a change that + // would leave applications already in progress unable to move. + license_type_in_use: + 'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.', + stage_fee_in_use: + 'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.', + form_schema_missing_protected_paths: + 'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.', }; export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string { diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index d703092b6..058211621 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -187,6 +187,11 @@ export interface LicenseType { feeCurrency: string; capitalThreshold: string | number | null; validityMonths: number; + /** + * A term in days instead of months, for licences shorter than a month can + * express. Wins over `validityMonths` when set; null keeps calendar months. + */ + validityDays?: number | null; /** * Target turnaround in hours. Null means this type is not tracked against * an SLA, which the grid renders as "—" rather than as instantly overdue. @@ -216,11 +221,39 @@ export interface LicenseType { // --------------------------------------------------- examined certificates /** Approval establishes eligibility; the certificate is earned by exam. */ requiresExamination?: boolean; + /** Assessment fee, due on submission before any officer review. */ + feeEligibility?: string | number | null; /** Fee per sitting. Falls back to `feeNewApplication` when null. */ feeExamination?: string | number | null; /** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */ feeCertificate?: string | number | null; + // ------------------------------------------------------- behaviour config + // Editable from the backoffice Behavior tab. Optional because the API has + // only recently begun returning them and older mocks/fixtures omit them. + + /** Licence or registration. Catalogue metadata; no behaviour hangs off it. */ + serviceKind?: ServiceKind; + /** Platform side effect fired when an application of this type completes. */ + completionEffect?: CompletionEffect | null; + /** Only ACTIVE registered seafarers may apply. */ + requiresSeafarerRegistration?: boolean; + /** Submission requires a current, unexpired medical certificate. */ + requiresValidMedical?: boolean; + /** Minimum VERIFIED sea time in days at submission. Null means no floor. */ + minSeaTimeDays?: number | null; + /** Whether several open drafts may exist at once, for per-asset registrations. */ + allowMultipleOpenDrafts?: boolean; + /** Days before expiry that renewal opens. */ + renewalWindowDays?: number; + /** Days before expiry to remind the holder, most distant first. */ + expiryReminderDays?: number[]; + /** + * Dotted `formData` path whose answer may appear on only one live + * application of this type. Null means no such rule. + */ + uniqueFormKeyPath?: string | null; + // ---------------------------------------------------------- STCW mapping certificateCategory?: CertificateCategory | null; stcwControlled?: boolean; @@ -264,7 +297,12 @@ export interface StcwCapacityRow { export interface DocumentRequirement { id: string; - licenseTypeId: string; + /** + * Null for a personal document — one every applicant keeps in their own + * vault regardless of what they apply for, rather than a slot on one + * licence's application form. + */ + licenseTypeId: string | null; key: string; name: Bilingual; description?: Bilingual; @@ -275,6 +313,14 @@ 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; isActive: boolean; } @@ -566,6 +612,15 @@ export type QueueSortField = /** Review → evaluation → (inspection) → approval, or the short registration course. */ export type WorkflowProfile = "STANDARD" | "REGISTRATION"; +/** A permission to operate, or a registration granting a status and a number. */ +export type ServiceKind = "LICENSE" | "REGISTRATION"; + +/** Platform side effect fired when an application reaches COMPLETED. */ +export type CompletionEffect = + | "REGISTER_SEAFARER" + | "REGISTER_VESSEL" + | "OPEN_SEAFARER_DOCUMENTS"; + /** Row counts behind the queue's saved-view tabs. */ export interface QueueCounts { unassigned: number; @@ -815,6 +870,31 @@ export interface IssuedLicense { certificateFileKey: string | null; } +/** + * One personal document as the backoffice manages it: every configured row + * sharing a key, which is one slot in the applicant's vault. Several rows mean + * the document is scoped to several licence types. + */ +export interface PersonalDocumentGroup { + key: string; + rows: DocumentRequirement[]; +} + +export interface PersonalDocumentFilter { + /** Matches the key and the name in either locale. */ + search?: string; + /** A licence type also matches the documents every licence asks for. */ + licenseTypeId?: string; + /** Narrows to the documents configured against no licence type at all. */ + globalOnly?: boolean; + sortBy?: 'sortOrder' | 'key' | 'name'; + sortDir?: 'ASC' | 'DESC'; + take?: number; + skip?: number; + /** Which locale `sortBy: "name"` sorts on. */ + locale?: 'en' | 'am'; +} + /** One sitting offered to a claimed examined-certificate application, scoped to its rank. */ export interface EligibleExam { id: string; diff --git a/libs/api/src/lib/features/personal-document/index.ts b/libs/api/src/lib/features/personal-document/index.ts new file mode 100644 index 000000000..19d08eb8f --- /dev/null +++ b/libs/api/src/lib/features/personal-document/index.ts @@ -0,0 +1,3 @@ +export * from './personal-document.types'; +export * from './personal-document.upload'; +export * from './personal-document-api'; diff --git a/libs/api/src/lib/features/personal-document/personal-document-api.ts b/libs/api/src/lib/features/personal-document/personal-document-api.ts new file mode 100644 index 000000000..80b92faa7 --- /dev/null +++ b/libs/api/src/lib/features/personal-document/personal-document-api.ts @@ -0,0 +1,40 @@ +import { baseApi } from '../../base-api'; +import type { PersonalDocumentSlot } from './personal-document.types'; + +const TAG = 'PersonalDocument' as const; +const LIST = { type: TAG, id: 'LIST' } as const; + +/** + * The applicant's own documents — identity card, photograph, education — + * kept against their profile rather than any one application, so they survive + * having no seafarer registration yet. + * + * Reads and deletes live here; the two uploads do not. `fetch` — what + * `fetchBaseQuery` runs on — cannot report how much of a request body has gone + * up, so they use XHR instead (`personal-document.upload.ts`) and the page + * refetches this query when one finishes. + */ +export const personalDocumentApi = baseApi + .enhanceEndpoints({ addTagTypes: [TAG] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({ + query: () => ({ url: '/profiles/me/documents' }), + providesTags: () => [LIST], + }), + + deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({ + query: (fileId) => ({ + url: `/profiles/me/documents/files/${fileId}`, + method: 'DELETE', + }), + invalidatesTags: (_r, error) => (error ? [] : [LIST]), + }), + }), + overrideExisting: false, + }); + +export const { + useGetMyPersonalDocumentsQuery, + useDeletePersonalDocumentFileMutation, +} = personalDocumentApi; diff --git a/libs/api/src/lib/features/personal-document/personal-document.types.ts b/libs/api/src/lib/features/personal-document/personal-document.types.ts new file mode 100644 index 000000000..7a6897186 --- /dev/null +++ b/libs/api/src/lib/features/personal-document/personal-document.types.ts @@ -0,0 +1,22 @@ +import type { AttachmentFile, Bilingual } from '../licensing/licensing.types'; + +/** + * One slot in the applicant's personal document vault, with whatever they + * have put in it. + * + * The slot itself is configuration: a document requirement that names no + * licence type applies to every licence, so the backoffice adds and retires + * these without a release. That is why the label, the accepted types and the + * limits arrive from the API rather than living in the portal. + */ +export interface PersonalDocumentSlot { + key: string; + name: Bilingual; + description: Bilingual | null; + /** How many files the slot holds; null means as many as the holder has. */ + maxFiles: number | null; + allowedMimeTypes: string[]; + maxSizeMb: number; + sortOrder: number; + files: AttachmentFile[]; +} diff --git a/libs/api/src/lib/features/personal-document/personal-document.upload.ts b/libs/api/src/lib/features/personal-document/personal-document.upload.ts new file mode 100644 index 000000000..1a95edc10 --- /dev/null +++ b/libs/api/src/lib/features/personal-document/personal-document.upload.ts @@ -0,0 +1,116 @@ +import { resolveTokenFromStorage } from '../../session'; +import type { PersonalDocumentSlot } from './personal-document.types'; + +const BASE_API_URL = + (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? + 'http://localhost:3000/api'; + +/** What the server says when it refuses a file — see ProfileDocumentsService. */ +export interface PersonalDocumentError { + message?: string; + [detail: string]: unknown; +} + +export type PersonalDocumentUploadResult = + | { ok: true; slot: PersonalDocumentSlot } + | { ok: false; error: PersonalDocumentError }; + +/** + * Uploads one file and reports how far it has got. + * + * XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no + * upload progress event, so a request body of any size is a spinner with + * nothing behind it. That is tolerable for a 5 MB scan and not for the video a + * slot can now be opened to, where the difference between "uploading" and + * "uploading, 12%" is the difference between waiting and reloading the page. + * + * The caller refetches the vault afterwards; nothing here touches the cache. + */ +function upload( + path: string, + method: 'POST' | 'PUT', + body: FormData, + onProgress?: (percent: number) => void, +): Promise { + return new Promise((resolve) => { + const request = new XMLHttpRequest(); + request.open(method, `${BASE_API_URL}${path}`); + + const token = resolveTokenFromStorage(); + if (token) request.setRequestHeader('Authorization', `Bearer ${token}`); + + request.upload.onprogress = (event) => { + // Not every browser knows the total for a streamed body; without it a + // percentage would be invented, so the caller keeps its spinner. + if (!event.lengthComputable || !onProgress) return; + onProgress(Math.round((event.loaded / event.total) * 100)); + }; + + request.onload = () => { + const parsed = parseBody(request.responseText); + if (request.status >= 200 && request.status < 300) { + resolve({ ok: true, slot: parsed as PersonalDocumentSlot }); + return; + } + resolve({ ok: false, error: toError(parsed, request.status) }); + }; + + // A dropped connection and a cancelled request both land here; neither + // carries a server message, so the caller falls back to its own wording. + request.onerror = () => resolve({ ok: false, error: {} }); + request.onabort = () => resolve({ ok: false, error: {} }); + + request.send(body); + }); +} + +function parseBody(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Nest wraps a thrown `BadRequestException({ message, ... })` as + * `{ message: { message, ... } }`, and a plain string message as + * `{ message: "slot_full" }`. Both are flattened to the object the UI + * translates by its `message` key. + */ +function toError(parsed: unknown, status: number): PersonalDocumentError { + const message = (parsed as { message?: unknown } | null)?.message; + if (typeof message === 'object' && message !== null) { + return message as PersonalDocumentError; + } + if (typeof message === 'string') return { message }; + return { message: `http_${status}` }; +} + +/** Adds one file to a personal document slot. */ +export function uploadPersonalDocumentFile(params: { + documentKey: string; + file: File; + onProgress?: (percent: number) => void; +}): Promise { + const body = new FormData(); + body.append('documentKey', params.documentKey); + body.append('file', params.file, params.file.name); + return upload('/profiles/me/documents', 'POST', body, params.onProgress); +} + +/** Swaps one file for another in the same slot. */ +export function replacePersonalDocumentFile(params: { + fileId: string; + file: File; + onProgress?: (percent: number) => void; +}): Promise { + const body = new FormData(); + body.append('file', params.file, params.file.name); + return upload( + `/profiles/me/documents/files/${params.fileId}`, + 'PUT', + body, + params.onProgress, + ); +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index f3ca62e0d..be2447e33 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -2,6 +2,7 @@ export * from "./lib/input/BilingualInput"; export * from "./lib/input/AmharicDatePicker"; export * from "./lib/feedback/ConfirmModal"; export * from "./lib/feedback/PdfPreviewModal"; +export * from "./lib/feedback/FilePreviewModal"; export * from "./lib/feedback/ModalFooter"; export * from "./lib/feedback/ApiErrorAlert"; export * from "./lib/feedback/notify"; diff --git a/libs/ui/src/lib/feedback/FilePreviewModal.tsx b/libs/ui/src/lib/feedback/FilePreviewModal.tsx new file mode 100644 index 000000000..1e8bf8d30 --- /dev/null +++ b/libs/ui/src/lib/feedback/FilePreviewModal.tsx @@ -0,0 +1,169 @@ +import { Anchor, Button, Group, Modal, Stack, Text, ThemeIcon } from '@mantine/core'; +import { IconExternalLink, IconFileUnknown } from '@tabler/icons-react'; + +/** How a file is shown, once its type is known. */ +type PreviewKind = 'image' | 'video' | 'audio' | 'embed' | 'unsupported'; + +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg']; +const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v']; +const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a']; +const EMBED_EXTENSIONS = ['pdf', 'txt', 'csv', 'json', 'xml']; + +/** + * What the browser can actually render, decided from the mime type where there + * is one and the URL's extension where there is not. + * + * Presigned links carry the storage key in the path, so the extension survives + * even when the caller only has a URL. `image/tiff` and `image/heic` are + * deliberately treated as images: Safari renders both, and everywhere else the + * `` fails visibly rather than an iframe offering a silent download. + */ +export function resolvePreviewKind(url: string, mimeType?: string | null): PreviewKind { + const mime = mimeType?.toLowerCase() ?? ''; + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('video/')) return 'video'; + if (mime.startsWith('audio/')) return 'audio'; + if (mime === 'application/pdf' || mime.startsWith('text/')) return 'embed'; + // Word, Excel and the rest: nothing renders them inline, and an iframe would + // quietly start a download instead of previewing anything. + if (mime) return 'unsupported'; + + const extension = extensionOf(url); + if (!extension) return 'embed'; + if (IMAGE_EXTENSIONS.includes(extension)) return 'image'; + if (VIDEO_EXTENSIONS.includes(extension)) return 'video'; + if (AUDIO_EXTENSIONS.includes(extension)) return 'audio'; + if (EMBED_EXTENSIONS.includes(extension)) return 'embed'; + return 'unsupported'; +} + +function extensionOf(url: string): string | null { + // Presigned URLs carry a query string; the path is the part with the name. + const path = url.split(/[?#]/)[0]; + const name = path.slice(path.lastIndexOf('/') + 1); + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : null; +} + +/** + * The one place a stored file gets opened anywhere in the app. + * + * Never `window.open` / `target="_blank"` a file that can be shown here — + * route it through this modal so the reviewer never loses their place to a new + * tab. What a slot accepts is configuration now, so this had to grow past the + * PDF it started as: a national ID arrives as a photograph, evidence arrives + * as video, and an academic record sometimes arrives as the Word file its + * institution issued. The last of those genuinely cannot be rendered by a + * browser, so it gets an honest panel and a link out rather than an iframe + * that silently downloads it. + */ +export function FilePreviewModal({ + opened, + onClose, + url, + title = 'Document', + mimeType, + /** Overrides the detected kind — for a blob URL with no extension. */ + kind, + labels, +}: { + opened: boolean; + onClose: () => void; + url: string; + title?: string; + mimeType?: string | null; + kind?: PreviewKind; + /** Supplied by the app so this stays out of the i18n bundles. */ + labels?: { unsupported?: string; openInNewTab?: string; close?: string }; +}) { + const resolved = kind ?? resolvePreviewKind(url, mimeType); + + return ( + + {url && resolved === 'image' && ( + {title} + )} + + {url && resolved === 'video' && ( + // Controls only, no autoplay: a review screen that starts making noise + // on open is a review screen people mute and then miss the audio on. + + )} + + {url && resolved === 'audio' && ( + + + + )} + + {url && resolved === 'embed' && ( +