From 6901e985fc972d1dff99be388daa2bd43adec9a1 Mon Sep 17 00:00:00 2001 From: Nati Date: Thu, 20 Aug 2026 12:41:46 +0000 Subject: [PATCH 1/3] feat: update exam stage actions and payment flow - Refactor ExamStageActions component to handle eligibility payment and retake exam actions. - Update MyApplicationsPage to use new retakeExam mutation instead of requestExamPayment. - Modify licensing API to include retakeExam mutation for handling exam fee requests for failed candidates. - Adjust mock data to reflect new application statuses and ensure consistency in eligibility and exam payment states. - Update licensing helpers to include new status labels and colors for eligibility payment states. - Revise licensing types to replace ELIGIBILITY_APPROVED with ELIGIBILITY_PAYMENT_PENDING and ELIGIBILITY_PAID for clarity in application status flow. --- .../features/license-review/config/actions.ts | 21 +- .../pages/LicenseQueuePage/actions.tsx | 6 +- .../pages/LicenseQueuePage/index.tsx | 14 + .../pages/LicenseReviewPage/index.tsx | 76 ++ .../certificates/pages/CertificatesPage.tsx | 32 +- .../certificates/pages/CoCApplicationPage.tsx | 1094 ----------------- .../licensing/components/ExamStageActions.tsx | 53 +- .../pages/MyApplicationsPage/actions.tsx | 9 +- .../pages/MyApplicationsPage/index.tsx | 15 +- apps/portal/src/app/router.tsx | 12 +- libs/api/src/lib/base-api/mock-data.ts | 2 +- .../lib/features/licensing/licensing-api.ts | 31 +- .../features/licensing/licensing.helpers.ts | 12 +- .../lib/features/licensing/licensing.types.ts | 8 +- 14 files changed, 231 insertions(+), 1154 deletions(-) delete mode 100644 apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index c5532deec..8653938e7 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -29,6 +29,7 @@ export type ActionId = | 'request-adjustment' | 'reject' | 'schedule-exam' + | 'record-exam-outcome' | 'confirm-payment' | 'schedule-issuance' | 'issue-certificate' @@ -68,7 +69,9 @@ export const ACTIONS: ActionDefinition[] = [ id: 'claim', tier: 'workflow', labelKey: 'review.actions.claim', - from: ['SUBMITTED'], + // Mirrors the CLAIM transition's `from` list: an examined cert (CoC/CoP) + // sits in ELIGIBILITY_PAID once its eligibility fee clears, not SUBMITTED. + from: ['SUBMITTED', 'ELIGIBILITY_PAID'], permissions: ['can:claim:license-application'], emphasis: 'light', }, @@ -198,7 +201,21 @@ export const ACTIONS: ActionDefinition[] = [ // Only after the examination fee clears — scheduling an unpaid candidate // is what the EXAM_PAID gate exists to prevent. from: ['EXAM_PAID'], - permissions: ['can:schedule:exam-candidate'], + // Matches the controller's guard on `:id/exam-scheduled` + // (`LICENSE_PERMISSIONS.MANAGE_EXAMS`) — the previous string didn't + // correspond to any real permission constant, so this button could never + // actually be granted to anyone. + permissions: ['can:manage:exams'], + emphasis: 'filled', + color: 'cyan', + }, + { + id: 'record-exam-outcome', + tier: 'primary', + labelKey: 'review.actions.recordExamOutcome', + // Only once the candidate has actually sat the exam. + from: ['EXAM_SCHEDULED'], + permissions: ['can:publish:exam-result'], emphasis: 'filled', color: 'cyan', }, diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx index 4362f2416..bd3eb3753 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx @@ -22,7 +22,11 @@ export function licenseQueueActionsColumn( cell: ({ row }) => handlers.claimable !== false && row.original.assignedOfficerId === null && - row.original.status === "SUBMITTED" ? ( + // Mirrors the CLAIM transition's `from` list: an examined cert + // (CoC/CoP) sits in ELIGIBILITY_PAID once its eligibility fee clears, + // not SUBMITTED. + (row.original.status === "SUBMITTED" || + row.original.status === "ELIGIBILITY_PAID") ? ( (); const [findings, setFindings] = useState(""); const [checklist, setChecklist] = useState< Record @@ -490,6 +494,11 @@ export function LicenseReviewPage() { case "schedule-exam": setScheduleExamOpen(true); return; + // Pass/fail plus an optional score, same reasoning as schedule-exam: + // needs its own inputs before anything is sent. + case "record-exam-outcome": + setExamOutcomeOpen(true); + return; case "copy-link": navigator.clipboard.writeText(window.location.href); notifications.show({ @@ -1173,6 +1182,73 @@ export function LicenseReviewPage() { }} /> + setExamOutcomeOpen(false)} + title={t("review.actions.recordExamOutcome", "Record exam outcome")} + > + + + {t( + "review.examOutcome.intro", + "Record the published result. A pass makes the certificate fee due; a fail leaves the application open for a retake.", + )} + + setExamScore(typeof v === "number" ? v : undefined)} + min={0} + /> + + + run( + async () => { + await recordExamOutcome({ + id, + passed: true, + score: examScore, + }).unwrap(); + setExamOutcomeOpen(false); + setExamScore(undefined); + }, + t("review.done.examPassed", "Exam result recorded — passed"), + ) + } + > + + + + run( + async () => { + await recordExamOutcome({ + id, + passed: false, + score: examScore, + }).unwrap(); + setExamOutcomeOpen(false); + setExamScore(undefined); + }, + t("review.done.examFailed", "Exam result recorded — not passed"), + ) + } + > + + + + + + setInspectionOpen(false)} diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index 9b7295f94..9e8879996 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -72,7 +72,8 @@ const STATUS_COLOR: Record = { RESUBMIT_REQUIRED: 'orange', INSPECTION_PENDING: 'grape', INSPECTION_COMPLETED: 'grape', - ELIGIBILITY_APPROVED: 'teal', + ELIGIBILITY_PAYMENT_PENDING: 'orange', + ELIGIBILITY_PAID: 'blue', EXAM_PAYMENT_PENDING: 'orange', EXAM_PAID: 'blue', EXAM_SCHEDULED: 'indigo', @@ -220,14 +221,25 @@ export function CertificatesPage() { {/* Tooltip needs a hoverable child even while the button itself is disabled, so the reason still shows on hover. */} - + + + + @@ -269,7 +281,7 @@ export function CertificatesPage() { My Applications {applications.length === 0 ? ( }> - No active CoC/CoP applications. Click "Apply for CoC / CoP" to start. + No active CoC/CoP applications. Click "Apply for CoC" or "Apply for CoP" to start. ) : ( diff --git a/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx b/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx deleted file mode 100644 index 667077cc3..000000000 --- a/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx +++ /dev/null @@ -1,1094 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Alert, - Badge, - Box, - Button, - Card, - Collapse, - Divider, - FileInput, - Group, - List, - Paper, - Select, - SimpleGrid, - Stack, - Stepper, - Text, - TextInput, - ThemeIcon, - Title, -} from '@mantine/core'; -import { - IconAlertCircle, - IconArrowLeft, - IconArrowRight, - IconAward, - IconBook2, - IconCheck, - IconChevronDown, - IconChevronUp, - IconCircleCheck, - IconClock, - IconCreditCard, - IconFileDescription, - IconInfoCircle, - IconShieldCheck, - IconUpload, -} from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; - -// --------------------------------------------------------------------------- -// STCW certificate catalog — sourced from the STCW Convention & Code -// --------------------------------------------------------------------------- - -export type Department = 'deck' | 'engine' | 'electro' | 'catering'; - -interface Competency { - area: string; - items: string[]; -} - -interface CertDef { - id: string; - dept: Department[]; - type: 'CoC' | 'CoP'; - stcwRef: string; - level: 'Support' | 'Operational' | 'Management'; - label: string; - eligibleRanks: string; - prerequisiteId: string | null; // must hold this cert first - minAge: number; - seaService: string; // human-readable sea-service requirement - mandatoryTraining: string[]; - competencies: Competency[]; - validityYears: number; - revalidationRule: string; - notes: string; -} - -const CERT_CATALOG: CertDef[] = [ - // ── DECK ────────────────────────────────────────────────────────────────── - { - id: 'rfpnw', - dept: ['deck'], - type: 'CoP', - stcwRef: 'Reg. II/4 — Code A-II/4', - level: 'Support', - label: 'Rating Forming Part of a Navigational Watch (RFPNW)', - eligibleRanks: 'Lookout, Helmsman, Watch Rating', - prerequisiteId: null, - minAge: 16, - seaService: 'At least 6 months approved sea-service/training; OR special training plus 2 months approved sea service with direct supervision.', - mandatoryTraining: ['Basic Safety Training (STCW VI/1)', 'Approved national RFPNW programme'], - competencies: [ - { area: 'Navigational Watch', items: ['Keep a safe lookout by sight and hearing', 'Helm orders and steering', 'Handover/relief procedures', 'Vessel communications and signals', 'Anchor watch routines'] }, - { area: 'Safety', items: ['Personal survival, fire prevention & fighting, elementary first aid, personal safety (VI/1)', 'Pollution prevention awareness'] }, - ], - validityYears: 5, - revalidationRule: 'No mandatory STCW periodic revalidation; national practice varies. Maintain Basic Safety evidence every 5 years (PST & FPFF elements).', - notes: 'Duties must be directly supervised by a qualified officer.', - }, - { - id: 'ab-deck', - dept: ['deck'], - type: 'CoP', - stcwRef: 'Reg. II/5 — Code A-II/5', - level: 'Support', - label: 'Able Seafarer Deck (AB)', - eligibleRanks: 'Able Seafarer, Deck Rating, Bosun candidate', - prerequisiteId: 'rfpnw', - minAge: 18, - seaService: 'Must already hold RFPNW CoP. Then: at least 18 months approved deck sea service while qualified as RFPNW; OR at least 12 months plus approved AB training (IMO MC 7.10).', - mandatoryTraining: ['RFPNW CoP (prerequisite)', 'Approved AB Deck training — IMO Model Course 7.10'], - competencies: [ - { area: 'Navigation', items: ['Maintain a safe navigational watch', 'Use of navigational aids including ECDIS', 'Determine compass error by celestial and terrestrial means', 'Contribute to monitoring and controlling vessel position'] }, - { area: 'Cargo Operations', items: ['Handle, stow and secure cargo', 'Use deck equipment and machinery', 'Rig and operate equipment used in cargo operations'] }, - { area: 'Ship Operations', items: ['Mooring and anchoring operations', 'Maintenance of the ship and equipment', 'Fuel and ballast transfers awareness'] }, - { area: 'Safety & Emergency', items: ['Operate survival craft and rescue boats', 'Fight and extinguish fires', 'Apply first aid', 'Contribute to prevention of marine pollution'] }, - ], - validityYears: 5, - revalidationRule: 'No mandatory STCW stand-alone periodic revalidation; maintain BST evidence every 5 years.', - notes: 'IMO Model Course 7.10 is the implementation guidance for this CoP.', - }, - { - id: 'oicnw', - dept: ['deck'], - type: 'CoC', - stcwRef: 'Reg. II/1 — Code A-II/1', - level: 'Operational', - label: 'Officer in Charge of a Navigational Watch — 500 GT or more (OICNW)', - eligibleRanks: 'Third Officer, Second Officer, Watch-keeping Officer', - prerequisiteId: null, - minAge: 18, - seaService: 'Option A: Approved training programme with approved Training Record Book plus at least 12 months approved sea service (including 6 months bridge watchkeeping under supervision). Option B: 36 months approved sea service (including 6 months supervised bridge watchkeeping).', - mandatoryTraining: [ - 'Approved deck officer education programme (or equivalent sea service)', - 'GMDSS training applicable under Chapter IV', - 'Bridge Resource Management — IMO MC 1.22', - 'ECDIS familiarization — IMO MC 1.27', - 'Leadership and Teamwork — IMO MC 1.39', - ], - competencies: [ - { area: 'Navigation at Operational Level', items: ['Plan and conduct a passage; determine position', 'Maintain a safe watch under COLREGS', 'Use of radar/ARPA, ECDIS and all bridge equipment', 'Respond to navigational emergencies'] }, - { area: 'Cargo Handling & Stowage', items: ['Cargo handling at operational level', 'Stowage planning and securing awareness', 'Load and stability calculations'] }, - { area: 'Control of Ship Operations', items: ['Monitor and control compliance with legal requirements', 'Prevent, control and fight fire', 'Operate life-saving appliances'] }, - { area: 'Marine Engineering', items: ['Basic knowledge of main and auxiliary machinery', 'Knowledge of electrical systems'] }, - { area: 'Radio Communication', items: ['Transmit and receive voice messages under GMDSS', 'Distress and safety procedures'] }, - { area: 'Leadership & Teamwork', items: ['Assign and prioritize resources', 'Effective communication with bridge team', 'Apply task and workload management'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11: demonstrate continued competence and hold valid medical certificate.', - notes: 'Near-coastal limitations may restrict the scope of the CoC. Assessment against table A-II/1 methods and criteria.', - }, - { - id: 'chief-mate-500', - dept: ['deck'], - type: 'CoC', - stcwRef: 'Reg. II/2 — Code A-II/2', - level: 'Management', - label: 'Chief Mate — Ships 500–3,000 GT (Management Level)', - eligibleRanks: 'Chief Mate on ships 500–3,000 GT', - prerequisiteId: 'oicnw', - minAge: 18, - seaService: 'Must hold OICNW CoC. Then: at least 12 months approved sea service as an officer in charge of a navigational watch.', - mandatoryTraining: ['OICNW CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management-level education programme — IMO MC 7.01'], - competencies: [ - { area: 'Navigation at Management Level', items: ['Plan voyages and conduct navigation at management level', 'Determine vessel position using all available means', 'Monitor bridge team performance'] }, - { area: 'Cargo Handling at Management Level', items: ['Plan and ensure safe loading, stowage, securing, care and unloading of cargo', 'Stability, trim and stress calculations and control'] }, - { area: 'Control of Ship Operations', items: ['Monitor and control compliance with legislative requirements', 'Ensure the safety of personnel, vessel, cargo and marine environment'] }, - { area: 'Leadership & Management', items: ['Manage and supervise deck department', 'Initiate and manage emergency procedures', 'Manage crew performance and well-being'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Assessment against table A-II/2.', - }, - { - id: 'master-500', - dept: ['deck'], - type: 'CoC', - stcwRef: 'Reg. II/2 — Code A-II/2', - level: 'Management', - label: 'Master — Ships 500–3,000 GT', - eligibleRanks: 'Master on ships 500–3,000 GT', - prerequisiteId: 'chief-mate-500', - minAge: 18, - seaService: 'Must hold Chief Mate CoC. Then: at least 36 months approved sea service in the deck department, reducible to 24 months if 12 months served as chief mate.', - mandatoryTraining: ['Chief Mate CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management-level programme — IMO MC 7.01'], - competencies: [ - { area: 'Command & Navigation', items: ['Full command responsibility for the safety of ship, crew, cargo and the environment', 'Management-level passage planning and execution', 'Emergency command during distress, fire, collision, grounding'] }, - { area: 'Cargo & Stability Management', items: ['Full responsibility for cargo operations and stability', 'Damage stability and emergency procedures'] }, - { area: 'Legal & Administrative', items: ['Compliance with international and flag-state law', 'Ship documentation, protest, log maintenance', 'Crew management and discipline'] }, - { area: 'Ship Management', items: ['Operate and manage all shipboard systems at management level', 'Crisis and emergency management'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Highest deck certificate. Assessment against table A-II/2.', - }, - { - id: 'master-3000', - dept: ['deck'], - type: 'CoC', - stcwRef: 'Reg. II/2 — Code A-II/2', - level: 'Management', - label: 'Master / Chief Mate — Ships 3,000 GT or more', - eligibleRanks: 'Master, Chief Mate on ships 3,000 GT or more', - prerequisiteId: 'master-500', - minAge: 20, - seaService: 'Must hold Master 500–3,000 GT CoC with adequate service on vessels of 3,000 GT or more, as determined by the Administration.', - mandatoryTraining: ['Master 500–3,000 GT CoC (prerequisite)', 'Approved management-level programme — IMO MC 7.01'], - competencies: [ - { area: 'All Master competencies', items: ['Same as Master 500–3,000 GT but for vessels of 3,000 GT or more and all trade areas'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Endorsement may be required for near-coastal limitations.', - }, - - // ── ENGINE ──────────────────────────────────────────────────────────────── - { - id: 'rfpew', - dept: ['engine'], - type: 'CoP', - stcwRef: 'Reg. III/4 — Code A-III/4', - level: 'Support', - label: 'Rating Forming Part of an Engineering Watch (RFPEW)', - eligibleRanks: 'Engine-room Rating, Watchkeeper', - prerequisiteId: null, - minAge: 16, - seaService: 'At least 6 months approved sea-service/training; OR special training plus 2 months approved sea service.', - mandatoryTraining: ['Basic Safety Training (STCW VI/1)', 'Approved RFPEW national programme — IMO MC 7.09'], - competencies: [ - { area: 'Engineering Watch Support', items: ['Understand orders and be understood', 'Use appropriate tools and equipment safely', 'Handover/relief procedures in engine room', 'Operate machinery under supervision', 'Maintain engineering watch routines'] }, - { area: 'Safety', items: ['Personal survival, fire prevention & fighting, elementary first aid, personal safety (VI/1)', 'Pollution prevention awareness'] }, - ], - validityYears: 5, - revalidationRule: 'No mandatory STCW periodic revalidation; maintain BST evidence every 5 years.', - notes: 'IMO Model Course 7.09 is the implementation guidance.', - }, - { - id: 'ab-engine', - dept: ['engine'], - type: 'CoP', - stcwRef: 'Reg. III/5 — Code A-III/5', - level: 'Support', - label: 'Able Seafarer Engine (AB Engine)', - eligibleRanks: 'AB Engine, Motorman, Senior Rating', - prerequisiteId: 'rfpew', - minAge: 18, - seaService: 'Must hold RFPEW CoP. Then: at least 12 months approved sea service in engine department; OR at least 6 months plus approved training (IMO MC 7.16).', - mandatoryTraining: ['RFPEW CoP (prerequisite)', 'Approved AB Engine training — IMO Model Course 7.16'], - competencies: [ - { area: 'Marine Engineering', items: ['Monitor and control propulsion machinery and auxiliaries', 'Maintain and repair mechanical systems', 'Safe use of workshop tools and equipment'] }, - { area: 'Electrical & Control Systems', items: ['Monitor electrical systems and distribution panels', 'Basic fault-finding in electrical/electronic control circuits'] }, - { area: 'Ship Operations', items: ['Contribute to fuelling operations', 'Bilge and ballast system operations', 'Safe transfer of liquids'] }, - { area: 'Safety & Emergency', items: ['Respond to engineering emergencies', 'Fire prevention and firefighting in engine room', 'Pollution prevention from engineering systems'] }, - ], - validityYears: 5, - revalidationRule: 'No mandatory STCW stand-alone periodic revalidation; maintain BST evidence every 5 years.', - notes: 'IMO Model Course 7.16 is the implementation guidance.', - }, - { - id: 'oicew', - dept: ['engine'], - type: 'CoC', - stcwRef: 'Reg. III/1 — Code A-III/1', - level: 'Operational', - label: 'Officer in Charge of an Engineering Watch (OICEW)', - eligibleRanks: 'Junior Engineer, Third/Second Engineer (watchkeeping)', - prerequisiteId: null, - minAge: 18, - seaService: 'Option A: Approved engineer training programme with approved Training Record Book plus at least 12 months sea service (including 6 months supervised engine-room watchkeeping). Option B: 36 months combined workshop/sea service, at least 30 months at sea with 6 months supervised watchkeeping.', - mandatoryTraining: [ - 'Approved engineering education programme (or equivalent)', - 'Engine-Room Resource Management — IMO MC 7.17', - 'Leadership and Teamwork — IMO MC 1.39', - ], - competencies: [ - { area: 'Marine Engineering at Operational Level', items: ['Safe engineering watch; monitor propulsion plant and auxiliaries', 'Operate, monitor and control boiler systems', 'Operate fuel, lubricating oil and bilge/ballast systems'] }, - { area: 'Electrical, Electronic & Control Engineering', items: ['Operate generators, switchboards and distribution systems', 'Maintain and repair electrical machinery', 'Monitor and operate automated systems'] }, - { area: 'Maintenance & Repair', items: ['Maintain and repair machinery and equipment', 'Perform planned maintenance', 'Safe use of workshop and maintenance tools'] }, - { area: 'Controlling Ship Operations', items: ['Apply pollution-prevention procedures', 'Maintain safety of personnel and machinery'] }, - { area: 'Leadership & Teamwork', items: ['Assign and prioritize resources in engine room', 'Effective communication with engineering team', 'Task and workload management'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Assessment against table A-III/1. Propulsion-type limitations may be endorsed nationally.', - }, - { - id: 'second-engineer-750', - dept: ['engine'], - type: 'CoC', - stcwRef: 'Reg. III/3 — Code A-III/3', - level: 'Management', - label: 'Second Engineer Officer — Ships 750–3,000 kW', - eligibleRanks: 'Second Engineer on ships 750–3,000 kW', - prerequisiteId: 'oicew', - minAge: 18, - seaService: 'Must hold OICEW CoC. Then: at least 12 months approved sea service as an assistant or qualified engineer officer.', - mandatoryTraining: ['OICEW CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management engineering programme — IMO MC 7.02'], - competencies: [ - { area: 'Management of Engine Room', items: ['Plan and schedule maintenance of propulsion plant and auxiliaries', 'Manage fuel, stores and spare parts'] }, - { area: 'Electrical Engineering at Management Level', items: ['Manage electrical systems including emergency power', 'High-voltage systems safety awareness'] }, - { area: 'Leadership & Management', items: ['Supervise and manage engine department personnel', 'Initiate and implement emergency procedures affecting engineering'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Assessment against table A-III/3.', - }, - { - id: 'chief-engineer-750', - dept: ['engine'], - type: 'CoC', - stcwRef: 'Reg. III/3 — Code A-III/3', - level: 'Management', - label: 'Chief Engineer Officer — Ships 750–3,000 kW', - eligibleRanks: 'Chief Engineer on ships 750–3,000 kW', - prerequisiteId: 'second-engineer-750', - minAge: 18, - seaService: 'Must hold Second Engineer CoC (750–3,000 kW). Then: at least 24 months approved sea service, of which at least 12 months served as qualified second engineer officer.', - mandatoryTraining: ['Second Engineer CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40'], - competencies: [ - { area: 'Full Engineering Department Management', items: ['Full responsibility for engineering plant operations, maintenance and repair', 'Fuel, lubricant and consumable management', 'Budget, records and documentation management'] }, - { area: 'Ship Operation & Safety', items: ['Ensure compliance with all engineering-related legal requirements', 'Emergency equipment maintenance readiness', 'Environmental compliance'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'A Second Engineer qualified for 3,000 kW+ may serve as Chief on ships below 3,000 kW if endorsed.', - }, - { - id: 'second-engineer-3000', - dept: ['engine'], - type: 'CoC', - stcwRef: 'Reg. III/2 — Code A-III/2', - level: 'Management', - label: 'Second Engineer Officer — Ships 3,000 kW or more', - eligibleRanks: 'Second Engineer on ships 3,000 kW or more', - prerequisiteId: 'chief-engineer-750', - minAge: 18, - seaService: 'Must hold OICEW CoC. Then: at least 12 months approved sea service as a qualified engineer officer.', - mandatoryTraining: ['OICEW CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40', 'Approved management engineering programme — IMO MC 7.02'], - competencies: [ - { area: 'Management-Level Marine Engineering (≥3,000 kW)', items: ['Plan, operate and maintain large propulsion plants', 'High-voltage management systems', 'Control engineering resource management at management level'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Assessment against table A-III/2.', - }, - { - id: 'chief-engineer-3000', - dept: ['engine'], - type: 'CoC', - stcwRef: 'Reg. III/2 — Code A-III/2', - level: 'Management', - label: 'Chief Engineer Officer — Ships 3,000 kW or more', - eligibleRanks: 'Chief Engineer on ships 3,000 kW or more', - prerequisiteId: 'second-engineer-3000', - minAge: 18, - seaService: 'Must hold Second Engineer (3,000 kW) CoC. Then: at least 36 months approved sea service, reducible to 24 months if 12 months served as second engineer.', - mandatoryTraining: ['Second Engineer (≥3,000 kW) CoC (prerequisite)', 'Leadership and Managerial Skills — IMO MC 1.40'], - competencies: [ - { area: 'Full Engineering Department — Large Ships', items: ['All competencies of Second Engineer plus full command of engineering department', 'Interface with shore management on technical matters', 'Crew resource management and team leadership'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Highest engine department certificate.', - }, - - // ── ELECTRO-TECHNICAL ───────────────────────────────────────────────────── - { - id: 'etr', - dept: ['electro'], - type: 'CoP', - stcwRef: 'Reg. III/7 — Code A-III/7', - level: 'Support', - label: 'Electro-Technical Rating (ETR)', - eligibleRanks: 'ETR, Electrician Rating', - prerequisiteId: null, - minAge: 18, - seaService: 'Either 12 months approved sea-service training/experience; OR approved training with at least 6 months sea service; OR technical qualifications meeting A-III/7 plus at least 3 months approved sea service.', - mandatoryTraining: ['Basic Safety Training (STCW VI/1)', 'Approved ETR training — IMO Model Course 7.15'], - competencies: [ - { area: 'Electrical Systems', items: ['Contribute to maintenance and repair of electrical/electronic systems', 'Operate and monitor electrical distribution panels', 'Cable and wiring maintenance'] }, - { area: 'Electronic & Control', items: ['Assist in maintenance of instrumentation and control systems', 'Basic fault identification in electronic circuits'] }, - { area: 'Safety & Environment', items: ['Prevent and extinguish fires (especially electrical fires)', 'Basic electrical safety; isolation and lock-out procedures', 'Pollution prevention awareness'] }, - ], - validityYears: 5, - revalidationRule: 'No mandatory STCW stand-alone periodic revalidation; maintain BST evidence every 5 years.', - notes: 'IMO Model Course 7.15 is the implementation guidance.', - }, - { - id: 'eto', - dept: ['electro'], - type: 'CoC', - stcwRef: 'Reg. III/6 — Code A-III/6', - level: 'Operational', - label: 'Electro-Technical Officer (ETO)', - eligibleRanks: 'Electro-Technical Officer', - prerequisiteId: 'etr', - minAge: 18, - seaService: 'Option A: Approved programme with Training Record Book plus at least 12 months sea service (at least 6 months in engine department). Option B: 36 months combined workshop/sea service, at least 30 months at sea in engine department.', - mandatoryTraining: [ - 'ETR CoP (prerequisite)', - 'Approved ETO programme — IMO Model Course 7.08', - 'Leadership and Teamwork — IMO MC 1.39', - ], - competencies: [ - { area: 'Electrical Systems at Operational Level', items: ['Monitor and control main electrical power systems', 'Operate and maintain generators, transformers, switchboards', 'High-voltage system safety and control', 'Emergency power systems'] }, - { area: 'Electronic Systems', items: ['Maintain and repair navigation and communication electronic systems', 'Instrumentation and measuring systems maintenance', 'Computer systems and ship networks management'] }, - { area: 'Automation & Control', items: ['Monitor and operate automated control systems', 'Fault diagnosis in electronic/control systems', 'Maintain programmable controllers and automation'] }, - { area: 'Maintenance & Repair', items: ['Plan and execute planned maintenance schedules', 'Safe use of test equipment and diagnostic tools'] }, - { area: 'Leadership & Teamwork', items: ['Communicate effectively with electro-technical team', 'Assign tasks and manage workload', 'Supervise ETR and other ratings'] }, - ], - validityYears: 5, - revalidationRule: 'Revalidation every 5 years under Regulation I/11.', - notes: 'Assessment against table A-III/6. Some administrations may specify high-voltage endorsements.', - }, - - // ── CATERING ───────────────────────────────────────────────────────────── - // STCW does not create department-specific CoC/CoP for catering. - // Catering seafarers qualify through basic safety + passenger-ship training. - // Ship's Cook is an MLC qualification. - { - id: 'basic-safety-catering', - dept: ['catering'], - type: 'CoP', - stcwRef: 'Reg. VI/1 — Code A-VI/1', - level: 'Support', - label: 'Basic Safety Training for Catering Personnel', - eligibleRanks: 'All catering crew', - prerequisiteId: null, - minAge: 16, - seaService: 'No sea-service prerequisite; training programme completion required.', - mandatoryTraining: [ - 'Personal Survival Techniques — IMO MC 1.19', - 'Fire Prevention and Fire Fighting — IMO MC 1.20', - 'Elementary First Aid — IMO MC 1.13', - 'Personal Safety and Social Responsibilities — IMO MC 1.21', - ], - competencies: [ - { area: 'Personal Survival Techniques', items: ['Don and use a lifejacket', 'Survive in the water', 'Board a liferaft from water', 'Take initial actions upon abandoning ship'] }, - { area: 'Fire Prevention & Fighting', items: ['Minimize risk of fire', 'Operate a fire extinguisher', 'Apply precautions when working with flammable materials'] }, - { area: 'Elementary First Aid', items: ['Administer first aid for burns, wounds, fractures', 'Perform CPR', 'Recognize and respond to medical emergencies'] }, - { area: 'Personal Safety & Social Responsibilities', items: ['Follow safe working practices', 'Report accidents and hazards', 'Environmental protection awareness'] }, - ], - validityYears: 5, - revalidationRule: 'PST and FPFF competence evidence required every 5 years (STCW A-VI/1). EFA and PSSR do not carry the same explicit 5-year refresh mandate.', - notes: 'Note: STCW does not create a catering-specific CoC or CoP. The Ship\'s Cook qualification is governed by MLC 2006 Standard A3.2 (national law), not STCW.', - }, - { - id: 'passenger-direct-service', - dept: ['catering'], - type: 'CoP', - stcwRef: 'Reg. V/2 para. 6 — Code A-V/2 para. 2', - level: 'Support', - label: 'Passenger Direct-Service Training', - eligibleRanks: 'Catering staff serving passengers on passenger ships', - prerequisiteId: 'basic-safety-catering', - minAge: 16, - seaService: 'No specific sea-service requirement; approved training completion required.', - mandatoryTraining: ['Basic Safety Training (prerequisite)', 'Passenger direct-service approved training — IMO MC 1.44'], - competencies: [ - { area: 'Passenger Communication & Assistance', items: ['Communicate emergency instructions to passengers', 'Demonstrate use of life-saving appliances', 'Assist passengers during embarkation, disembarkation and muster', 'Provide assistance to passengers with special needs'] }, - ], - validityYears: 5, - revalidationRule: 'Refresher/evidence every 5 years.', - notes: 'Required for catering personnel directly serving passengers on passenger ships.', - }, -]; - -// --------------------------------------------------------------------------- -// Mock — what the seafarer currently holds (simulated from profile) -// --------------------------------------------------------------------------- -const MOCK_PROFILE = { - department: 'deck' as Department, - seamanBookNo: 'SB-2024-0001', - photoRef: 'Photo on file (Passport Size)', - heldCertIds: ['rfpnw', 'ab-deck'], // already issued certificates - medicalExpired: false, // true = medical cert expired → user must upload fresh one -}; - -// --------------------------------------------------------------------------- -// Documents: sea service + one upload per competency area + TRB + optional medical -// --------------------------------------------------------------------------- -interface DocSlot { key: string; label: string; description: string; required: boolean; isCompetency?: boolean } - -const FIRST_CERT_IDS = new Set(['rfpnw', 'rfpew', 'etr', 'basic-safety-catering']); - -function buildDocSlots(cert: CertDef | null, medicalExpired: boolean): DocSlot[] { - if (!cert) return []; - - const isFirst = FIRST_CERT_IDS.has(cert.id); - - const slots: DocSlot[] = [ - { - key: 'sea-service', - label: 'Sea Service Record / Discharge Book', - description: 'Certified copy showing total approved sea time satisfying the STCW requirement for this certificate.', - required: true, - }, - ]; - - // One upload slot per competency area - cert.competencies.forEach((comp) => { - slots.push({ - key: `comp-${comp.area.toLowerCase().replace(/[^a-z0-9]/g, '-')}`, - label: `Certificate — ${comp.area}`, - description: `Upload the certificate(s) or documentary evidence proving competency in: ${comp.items.slice(0, 3).join('; ')}${comp.items.length > 3 ? '; and more.' : '.'}`, - required: true, - isCompetency: true, - }); - }); - - // TRB — mandatory for all certs except the very first entry-level ones - slots.push({ - key: 'trb', - label: 'Training Record Book (TRB)', - description: isFirst - ? 'Your Training Record Book if available. Not mandatory for this entry-level certificate.' - : 'Your completed and signed Training Record Book. An EMA officer will physically inspect this document. Mandatory.', - required: !isFirst, - }); - - if (medicalExpired) { - slots.push({ - key: 'medical', - label: 'Valid Medical Fitness Certificate', - description: 'Your medical certificate on file has expired. Upload a current valid medical fitness certificate.', - required: true, - }); - } - - return slots; -} - -// --------------------------------------------------------------------------- -// Level badge -// --------------------------------------------------------------------------- -const LEVEL_COLOR: Record = { Support: 'gray', Operational: 'blue', Management: 'violet' }; - -// --------------------------------------------------------------------------- -// Competency accordion -// --------------------------------------------------------------------------- -function CompetencyPanel({ cert }: { cert: CertDef }) { - const [open, setOpen] = useState(false); - return ( - - setOpen((o) => !o)}> - - - - - {cert.label} - {cert.level} - {cert.type} - - {open ? : } - - - - - - - STCW Reference - {cert.stcwRef} - - - Eligible Ranks - {cert.eligibleRanks} - - - Minimum Age - {cert.minAge} years - - - Validity - {cert.validityYears} years — {cert.revalidationRule} - - - - - - - Sea Service Requirement - {cert.seaService} - - - - Mandatory Training - - {cert.mandatoryTraining.map((t) => {t})} - - - - - - - Competency Areas (STCW Tables) - - {cert.competencies.map((comp) => ( - - {comp.area} - - {comp.items.map((item) => {item})} - - - ))} - - - - {cert.notes && ( - } p="xs"> - {cert.notes} - - )} - - - - ); -} - -// --------------------------------------------------------------------------- -// Main page -// --------------------------------------------------------------------------- -export function CoCApplicationPage() { - const navigate = useNavigate(); - const [step, setStep] = useState(0); - - // Derive available certs for this department - const deptCerts = CERT_CATALOG.filter((c) => c.dept.includes(MOCK_PROFILE.department)); - - // Which are already held - const held = new Set(MOCK_PROFILE.heldCertIds); - - // Eligible: prerequisite must be held (or null) - const eligible = deptCerts.filter((c) => { - if (held.has(c.id)) return false; // already issued - if (c.prerequisiteId && !held.has(c.prerequisiteId)) return false; // prereq not met - return true; - }); - - const selectOptions = eligible.map((c) => ({ - value: c.id, - label: `[${c.type}] ${c.label}`, - })); - - // Step 0 - const [selectedId, setSelectedId] = useState(null); - const selectedCert = CERT_CATALOG.find((c) => c.id === selectedId) ?? null; - - // Step 1 — documents (recomputed when selected cert changes) - const docSlots = buildDocSlots(selectedCert, MOCK_PROFILE.medicalExpired); - const [docs, setDocs] = useState>({}); - const setDoc = (key: string, f: File | null) => setDocs((p) => ({ ...p, [key]: f })); - - // Step 2 — payment - const [paymentMethod, setPaymentMethod] = useState(null); - const [paymentRef, setPaymentRef] = useState(''); - const [paymentDate, setPaymentDate] = useState(''); - const [paymentFile, setPaymentFile] = useState(null); - - const docsStepOk = docSlots.filter((d) => d.required).every((d) => !!docs[d.key]); - const payOk = !!paymentMethod && paymentRef.trim().length > 0 && paymentDate.length > 0 && !!paymentFile; - - const FEES = [ - { label: 'Application Fee', amount: selectedCert?.type === 'CoC' ? 800 : 300 }, - { label: 'Examination Fee', amount: selectedCert?.type === 'CoC' ? 400 : 0 }, - { label: 'Certificate Issuance Fee', amount: 200 }, - ].filter((f) => f.amount > 0); - const TOTAL = FEES.reduce((s, f) => s + f.amount, 0); - - const EXAM_VENUES = [ - { value: 'addis', label: 'EMA HQ — Addis Ababa' }, - { value: 'djibouti-link', label: 'EMA Regional Office — Djibouti Liaison' }, - ]; - - const handleSubmit = () => { - notify.success(`Application submitted! Reference: ${selectedCert?.type}-APP-2025-${Math.floor(Math.random() * 9000 + 1000)}`); - navigate('/certificates'); - }; - - const deptLabel: Record = { - deck: 'Deck', engine: 'Engine', electro: 'Electro-Technical', catering: 'Catering', - }; - - return ( - - - - - -
- Certificate Application — {deptLabel[MOCK_PROFILE.department]} Department - STCW Certificate of Competency / Certificate of Proficiency — Ethiopian Maritime Authority -
- - {/* Profile info pulled from system */} - - - - - Seaman Book: - {MOCK_PROFILE.seamanBookNo} - from system - - - - Photo: - {MOCK_PROFILE.photoRef} - from system - - - Your Seaman Book number and passport-size photo are automatically included from your profile. No need to upload them again. - - - { if (s < step) setStep(s); }} size="sm" color="blue"> - } /> - } /> - } /> - } /> - - - {/* ── STEP 0 — Select certificate ── */} - {step === 0 && ( - - - - Select the Certificate You Are Applying For - - {eligible.length === 0 ? ( - }> - No certificates available at this time. - - You have either completed all certificates for your department, or you need to obtain a prerequisite certificate first. Contact EMA for guidance. - - - ) : ( - ({ value: op, label: op }))} + value={operator} + onChange={(v) => v && setOperator(v as Operator)} + allowDeselect={false} + /> + + {operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && ( + ({ + value: o.value, + label: localized(o.label) || o.value, + }))} + multiple={undefined} + value={null} + onChange={(v) => { + if (!v) return; + const current = (value?.in ?? []) as string[]; + if (!current.includes(v)) setInValues([...current, v]); + }} + /> + )} + + {operator === 'in' && target?.field.type !== 'SELECT' && ( + + setInValues( + e.currentTarget.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + /> + )} + + + {operator === 'in' && (value?.in?.length ?? 0) > 0 && ( + + {(value?.in ?? []).map((v, i) => ( + setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))} + title={t('certReq.condition.removeValue', 'Click to remove')} + > + {String(v)} × + + ))} + + )} + + {!target && value?.field && ( + + {t( + 'certReq.condition.unknownField', + 'This path is not a field in the current schema yet — it will still be saved as typed.', + )} + + )} + + )} + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx new file mode 100644 index 000000000..6d59af5f6 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementEditorDrawer.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Checkbox, + Divider, + Drawer, + MultiSelect, + NumberInput, + Select, + Stack, + Text, + TextInput, +} 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 { ConditionBuilder, type ConditionValue } from './ConditionBuilder'; +import type { ConditionTarget } from '../config/schema-paths'; + +const MIME_OPTIONS = [ + { value: 'application/pdf', label: 'PDF' }, + { value: 'image/jpeg', label: 'JPEG' }, + { value: 'image/png', label: 'PNG' }, +]; + +type DraftRequirement = Omit; + +function emptyDraft(applicationKind: ApplicationKind): DraftRequirement { + return { + key: '', + name: { en: '', am: '' }, + applicationKind, + mode: 'ALWAYS', + allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'], + maxSizeMb: 5, + requiresValidityDates: false, + allowMultiple: false, + sortOrder: 0, + }; +} + +/** Adds/edits one document requirement slot for a licence type + application kind. */ +export function DocumentRequirementEditorDrawer({ + opened, + onClose, + requirement, + defaultApplicationKind, + onSave, + palette, + conditionTargets, + saving, +}: { + opened: boolean; + onClose: () => void; + /** Null = adding a new requirement. */ + requirement: DocumentRequirement | null; + defaultApplicationKind: ApplicationKind; + onSave: (draft: DraftRequirement) => void; + palette: FormSchemaPalette | undefined; + conditionTargets: ConditionTarget[]; + saving: boolean; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(emptyDraft(defaultApplicationKind)); + const [keyError, setKeyError] = useState(null); + const isNew = !requirement; + + useEffect(() => { + if (opened) { + setDraft( + requirement + ? { + key: requirement.key, + name: { ...requirement.name }, + description: requirement.description ? { ...requirement.description } : undefined, + applicationKind: requirement.applicationKind, + mode: requirement.mode, + conditionExpression: requirement.conditionExpression, + allowedMimeTypes: requirement.allowedMimeTypes, + maxSizeMb: requirement.maxSizeMb, + requiresValidityDates: requirement.requiresValidityDates, + allowMultiple: requirement.allowMultiple, + sortOrder: requirement.sortOrder, + } + : emptyDraft(defaultApplicationKind), + ); + setKeyError(null); + } + }, [opened, requirement, defaultApplicationKind]); + + function save() { + if (!draft.key.trim()) { + setKeyError(t('certReq.doc.keyRequired', 'Key is required')); + return; + } + if (!draft.name.en?.trim()) return; + if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) { + 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, + }); + } + + return ( + {isNew ? t('certReq.doc.add', 'Add document requirement') : t('certReq.doc.edit', 'Edit document requirement')}} + > + + setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + /> + + setDraft((d) => ({ ...d, name: v }))} + /> + + setDraft((d) => ({ ...d, description: v }))} + /> + + v && setDraft((d) => ({ ...d, mode: v as DraftRequirement['mode'] }))} + allowDeselect={false} + /> + + {draft.mode === 'CONDITIONAL' && ( + <> + + setDraft((d) => ({ ...d, conditionExpression: v ?? undefined }))} + targets={conditionTargets} + palette={palette} + allowClear={false} + /> + + )} + + setDraft((d) => ({ ...d, allowedMimeTypes: v }))} + /> + + setDraft((d) => ({ ...d, maxSizeMb: typeof v === 'number' ? v : d.maxSizeMb }))} + /> + + setDraft((d) => ({ ...d, requiresValidityDates: e.currentTarget.checked }))} + /> + + setDraft((d) => ({ ...d, allowMultiple: e.currentTarget.checked }))} + /> + + setDraft((d) => ({ ...d, sortOrder: typeof v === 'number' ? v : d.sortOrder }))} + /> + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx new file mode 100644 index 000000000..1c2510611 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/DocumentRequirementsTab.tsx @@ -0,0 +1,203 @@ +import { useMemo, useState } from 'react'; +import { ActionIcon, Alert, Badge, Button, Card, Group, Modal, Stack, Text, Title } from '@mantine/core'; +import { IconAlertCircle, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { EmptyState, ErrorState, ModalFooter, PageLoader } from '@ema-platform/ui'; +import { + extractErrorMessage, + useCreateDocumentRequirementMutation, + useDeleteDocumentRequirementMutation, + useGetDocumentRequirementsQuery, + useGetFormSchemaPaletteQuery, + useLocalized, + useUpdateDocumentRequirementMutation, + type ApplicationKind, + type DocumentRequirement, + type LicenseType, +} from '@ema-platform/api'; +import { collectConditionTargets } from '../config/schema-paths'; +import { useRequirementActions } from '../hooks/useRequirementActions'; +import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer'; + +const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL']; + +const MODE_COLOR: Record = { + ALWAYS: 'blue', + CONDITIONAL: 'violet', + OPTIONAL: 'gray', +}; + +/** + * Document upload requirements for one licence type, grouped by application + * kind (a new-application slot and its renewal counterpart are different + * rows even when they share a key). Every edit is a real CRUD call the + * moment the admin confirms it — there is no separate "save all" step here, + * unlike the form schema tab's whole-document replace. + */ +export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseType }) { + const { t } = useTranslation(); + const localized = useLocalized(); + const run = useRequirementActions(); + + const { data, isLoading, isError, error, refetch } = useGetDocumentRequirementsQuery(); + const { data: palette } = useGetFormSchemaPaletteQuery(); + const [createRequirement, { isLoading: creating }] = useCreateDocumentRequirementMutation(); + const [updateRequirement, { isLoading: updating }] = useUpdateDocumentRequirementMutation(); + const [deleteRequirement] = useDeleteDocumentRequirementMutation(); + + const [editorState, setEditorState] = useState<{ kind: ApplicationKind; requirement: DocumentRequirement | null } | null>(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const requirements = useMemo( + () => (data?.items ?? []).filter((r) => r.licenseTypeId === licenseType.id), + [data, licenseType.id], + ); + const conditionTargets = collectConditionTargets(licenseType.formSchema.sections); + + async function handleSave(draft: Omit) { + const ok = await run( + () => + editorState?.requirement + ? updateRequirement({ id: editorState.requirement.id, ...draft }).unwrap() + : createRequirement({ ...draft, licenseTypeId: licenseType.id }).unwrap(), + editorState?.requirement + ? t('certReq.doc.updated', 'Document requirement updated') + : t('certReq.doc.created', 'Document requirement added'), + ); + if (ok) setEditorState(null); + } + + async function confirmDelete() { + if (!deleteTarget) return; + const ok = await run( + () => deleteRequirement(deleteTarget.id).unwrap(), + t('certReq.doc.deleted', 'Document requirement removed'), + ); + if (ok) setDeleteTarget(null); + } + + if (isLoading) return ; + + if (isError) { + return ( + refetch()} + icon={IconAlertCircle} + /> + ); + } + + return ( + + + {t( + 'certReq.doc.subtitle', + 'What an applicant must upload for this licence type, split by new application and renewal.', + )} + + + {KINDS.map((kind) => { + const rows = requirements + .filter((r) => r.applicationKind === kind) + .sort((a, b) => a.sortOrder - b.sortOrder); + + return ( + + + + {kind === 'NEW' ? t('certReq.doc.kindNew', 'New application') : t('certReq.doc.kindRenewal', 'Renewal')} + + + + + {rows.length === 0 ? ( + + {t('certReq.doc.emptyKind', 'No document requirements for this application kind yet.')} + + ) : ( + + {rows.map((req) => ( + + +
+ + {localized(req.name) || req.key} + {req.mode} + {req.allowMultiple && {t('certReq.doc.multiple', 'multiple')}} + + + key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')} + + {req.mode === 'CONDITIONAL' && req.conditionExpression?.field && ( + + {t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '} + {req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`} + {req.conditionExpression.notEquals !== undefined && `≠ ${req.conditionExpression.notEquals}`} + {req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`} + {req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))} + + )} +
+ + setEditorState({ kind, requirement: req })}> + + + setDeleteTarget(req)}> + + + +
+
+ ))} +
+ )} +
+ ); + })} + + {requirements.length === 0 && ( + + )} + + setEditorState(null)} + requirement={editorState?.requirement ?? null} + defaultApplicationKind={editorState?.kind ?? 'NEW'} + onSave={handleSave} + palette={palette} + conditionTargets={conditionTargets} + saving={creating || updating} + /> + + setDeleteTarget(null)} title={t('certReq.doc.delete', 'Delete document requirement')} size="sm"> + + + {t('certReq.doc.deleteWarning', 'Applicants already relying on this slot will no longer see it. This cannot be undone.')} + + + {t('certReq.doc.deleteConfirm', 'Remove "{{name}}"?', { + name: deleteTarget ? localized(deleteTarget.name) || deleteTarget.key : '', + })} + + + + + + + +
+ ); +} diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx new file mode 100644 index 000000000..53cd32174 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-requirements/components/FieldEditorDrawer.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Checkbox, + Divider, + Drawer, + Group, + NumberInput, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { BilingualInput, ModalFooter } from '@ema-platform/ui'; +import type { FormFieldConfig, FormSchemaPalette } from '@ema-platform/api'; +import { ConditionBuilder, type ConditionValue } from './ConditionBuilder'; +import type { ConditionTarget } from '../config/schema-paths'; + +const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +function emptyField(): FormFieldConfig { + return { key: '', label: { en: '', am: '' }, type: 'TEXT' }; +} + +/** Adds/edits one field within a section. Options only show for SELECT. */ +export function FieldEditorDrawer({ + opened, + onClose, + field, + onSave, + palette, + conditionTargets, +}: { + opened: boolean; + onClose: () => void; + /** Null = adding a new field. */ + field: FormFieldConfig | null; + onSave: (field: FormFieldConfig) => void; + palette: FormSchemaPalette | undefined; + conditionTargets: ConditionTarget[]; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(emptyField()); + const [keyError, setKeyError] = useState(null); + + useEffect(() => { + if (opened) { + setDraft(field ? { ...field, label: { ...field.label } } : emptyField()); + setKeyError(null); + } + }, [opened, field]); + + const typeInfo = palette?.fieldTypes.find((f) => f.type === draft.type); + const isNew = !field; + + function save() { + if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) { + setKeyError(t('certReq.field.keyInvalid', 'Key must start with a letter and contain only letters, numbers, underscores')); + return; + } + if (!draft.label.en?.trim()) { + setKeyError(null); + return; + } + onSave({ + ...draft, + key: draft.key.trim(), + options: typeInfo?.supportsOptions ? draft.options : undefined, + min: typeInfo?.supportsRange ? draft.min : undefined, + max: typeInfo?.supportsRange ? draft.max : undefined, + maxLength: typeInfo?.supportsMaxLength ? draft.maxLength : undefined, + }); + } + + function addOption() { + setDraft((d) => ({ + ...d, + options: [...(d.options ?? []), { value: '', label: { en: '', am: '' } }], + })); + } + + function updateOption(index: number, patch: Partial<{ value: string; label: { en: string; am: string } }>) { + setDraft((d) => ({ + ...d, + options: (d.options ?? []).map((o, i) => (i === index ? { ...o, ...patch } : o)), + })); + } + + function removeOption(index: number) { + setDraft((d) => ({ ...d, options: (d.options ?? []).filter((_, i) => i !== index) })); + } + + return ( + {isNew ? t('certReq.field.add', 'Add field') : t('certReq.field.edit', 'Edit field')}} + > + + setDraft((d) => ({ ...d, key: e.currentTarget.value }))} + /> + + setDraft((d) => ({ ...d, label: v }))} + /> + + } + maw={480} + /> + + {selectedType && ( + + + }> + {t('certReq.tabSchema', 'Form schema')} + + }> + {t('certReq.tabDocuments', 'Document requirements')} + + + + + + + + + + + + )} + + )} +
+ + ); +} + +export default CertificateRequirementsPage; diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 043bc95a2..6fe440ddc 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -33,6 +33,7 @@ export const am: Translations = { allApplications: "ሁሉም ማመልከቻዎች", licenceRegister: "የፈቃድ መዝገብ", certificateDesigner: "የምስክር ወረቀት ንድፍ", + certificateRequirements: "የምስክር ወረቀት መስፈርቶች", byType: "በዓይነት", typeFreightForwarder: "የጭነት አስተላላፊ", typeShippingAgent: "የመርከብ ወኪል", @@ -1171,6 +1172,124 @@ export const am: Translations = { noPublishPermission: "ንድፎችን ማተም አይችሉም", }, + certReq: { + title: "የምስክር ወረቀት መስፈርቶች", + subtitle: + "ለፈቃድ ዓይነት አመልካቾች መሙላት ያለባቸውን የቅጽ መስኮች እና የሰነድ ሰቀላዎች ያዋቅሩ፣ መስፈርቱ መቼ ተግባራዊ እንደሚሆንም ጨምሮ።", + licenseType: "የፈቃድ ዓይነት", + selectLicenseType: "የፈቃድ ዓይነት ይምረጡ", + loading: "የፈቃድ ዓይነቶችን በመጫን ላይ…", + loadFailed: "የፈቃድ ዓይነቶችን መጫን አልተቻለም", + tabSchema: "የቅጽ ቅንብር", + tabDocuments: "የሰነድ መስፈርቶች", + cancel: "ይቅር", + delete: "ሰርዝ", + saveChanges: "ለውጦችን አስቀምጥ", + actionFailed: "ተግባሩ አልተሳካም", + condition: { + title: "የሚታይበት ሁኔታ", + badge: "ሁኔታዊ", + enable: "ሁኔታ ሲሟላ ብቻ ተግባራዊ ይሁን", + field: "የመስክ መንገድ", + fieldHelp: "ወደ ቅጹ የነጥብ መንገድ፣ ለምሳሌ sectionKey.fieldKey", + operator: "አመልካች", + value: "ዋጋ", + values: "ከእነዚህ አንዱ", + removeValue: "ለማስወገድ ይጫኑ", + isSet: "ተሞልቷል", + isNotSet: "አልተሞላም", + unknownField: "ይህ መንገድ በአሁኑ ቅንብር ውስጥ ያለ መስክ አይደለም — ቢሆንም እንደተጻፈው ይቀመጣል።", + }, + schema: { + subtitle: + "አመልካቹ ለዚህ የፈቃድ ዓይነት የሚያየው ክፍሎች እና መስኮች። አንድ ቡድን የሚጋሩ ክፍሎች በአንድ የዊዛርድ ደረጃ ላይ አብረው ይታያሉ።", + checkErrors: "ስህተቶችን ፈትሽ", + noIssues: "ምንም ችግር አልተገኘም", + issuesFound: "ችግሮች ተገኝተዋል", + save: "ቅንብር አስቀምጥ", + saved: "የቅጽ ቅንብር ተቀምጧል", + empty: "እስካሁን ክፍል የለም", + emptyBody: "ለዚህ የፈቃድ ዓይነት ቅጽ ለመገንባት ክፍል ይጨምሩ።", + }, + section: { + add: "ክፍል ጨምር", + edit: "ክፍል አርትዕ", + delete: "ክፍል ሰርዝ", + deleteConfirm: '"{{name}}"ን እና ሁሉንም መስኮቹን ከዚህ ቅንብር ማስወገድ ይፈልጋሉ?', + key: "የክፍል ቁልፍ", + keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ", + keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም", + keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት", + title: "ርዕስ", + description: "መግለጫ", + group: "የዊዛርድ ደረጃ ቡድን", + groupHelp: "አንድ ቡድን የሚጋሩ ክፍሎች በአንድ ደረጃ ላይ አብረው ይታያሉ", + groupBadge: "ቡድን", + groupOrder: "የቡድን ቅደም ተከተል", + sortOrder: "የቅደም ተከተል ቁጥር", + }, + field: { + add: "መስክ ጨምር", + edit: "መስክ አርትዕ", + delete: "መስክ ሰርዝ", + deleteConfirm: '"{{name}}"ን ከዚህ ክፍል ማስወገድ ይፈልጋሉ?', + key: "የመስክ ቁልፍ", + keyHelp: "ፊደላት፣ ቁጥሮች እና underscore ብቻ — የቅጽ መረጃ ቁልፍ ይሆናል", + keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም", + keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት", + label: "መለያ", + type: "የመስክ ዓይነት", + required: "የግድ ያስፈልጋል", + placeholder: "ምሳሌ ጽሑፍ", + helpText: "የእገዛ ጽሑፍ", + min: "ዝቅተኛ", + max: "ከፍተኛ", + maxLength: "ከፍተኛ ርዝመት", + options: "አማራጮች", + addOption: "አማራጭ ጨምር", + optionValue: "ዋጋ", + optionLabel: "መለያ", + }, + doc: { + subtitle: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለበት፣ በአዲስ ማመልከቻ እና በዕድሳት የተከፈለ።", + add: "የሰነድ መስፈርት ጨምር", + edit: "የሰነድ መስፈርት አርትዕ", + delete: "የሰነድ መስፈርት ሰርዝ", + deleteWarning: "በዚህ ቦታ ላይ የሚተማመኑ አመልካቾች ከዚህ በኋላ አያዩትም። ይህ መመለስ አይቻልም።", + deleteConfirm: '"{{name}}"ን ማስወገድ ይፈልጋሉ?', + created: "የሰነድ መስፈርት ተጨምሯል", + updated: "የሰነድ መስፈርት ተዘምኗል", + deleted: "የሰነድ መስፈርት ተወግዷል", + loading: "የሰነድ መስፈርቶችን በመጫን ላይ…", + loadFailed: "የሰነድ መስፈርቶችን መጫን አልተቻለም", + empty: "ምንም የሰነድ መስፈርት አልተዋቀረም", + emptyBody: "አመልካቹ ለዚህ የፈቃድ ዓይነት መስቀል ያለባቸውን ሰነዶች ይጨምሩ።", + emptyKind: "ለዚህ የማመልከቻ ዓይነት እስካሁን የሰነድ መስፈርት የለም።", + key: "ቁልፍ", + keyHelp: "ይህን የሰነድ ቦታ የሚለይ ቋሚ መጠሪያ", + keyLocked: "ከተፈጠረ በኋላ ቁልፍ መቀየር አይቻልም", + keyRequired: "ቁልፍ ያስፈልጋል", + name: "ስም", + description: "መግለጫ", + applicationKind: "የማመልከቻ ዓይነት", + kindNew: "አዲስ ማመልከቻ", + kindRenewal: "ዕድሳት", + kindLocked: "ከተፈጠረ በኋላ የማመልከቻ ዓይነት መቀየር አይቻልም", + mode: "ዘዴ", + modeAlways: "ሁልጊዜ ያስፈልጋል", + modeConditional: "ሁኔታ ሲሟላ ያስፈልጋል", + modeOptional: "አማራጭ ስቀላ", + conditionRequired: "ሁኔታዊ መስፈርት ሁኔታ ያስፈልገዋል", + allowedTypes: "የተፈቀዱ የፋይል ዓይነቶች", + maxSize: "ከፍተኛ የፋይል መጠን (MB)", + requiresValidity: "የቀን ገደብ ያስፈልጋል", + allowMultiple: "ብዙ ስቀላዎችን ፍቀድ", + multiple: "ብዙ", + sortOrder: "የቅደም ተከተል ቁጥር", + when: "መቼ", + }, + }, + seafarerRegistry: { title: "የመርከበኞች መዝገብ", profileCount_one: "{{count}} መገለጫ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index fa113749a..30c53417b 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -32,6 +32,7 @@ export const en = { allApplications: 'All Applications', licenceRegister: 'Licence Register', certificateDesigner: 'Certificate Designer', + certificateRequirements: 'Certificate Requirements', byType: 'By Type', typeFreightForwarder: 'Freight Forwarder', typeShippingAgent: 'Shipping Agent', @@ -1173,6 +1174,124 @@ export const en = { noPublishPermission: 'You cannot publish designs', }, + certReq: { + title: 'Certificate requirements', + subtitle: + 'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.', + licenseType: 'Licence type', + selectLicenseType: 'Select a licence type', + loading: 'Loading licence types…', + loadFailed: 'Could not load licence types', + tabSchema: 'Form schema', + tabDocuments: 'Document requirements', + cancel: 'Cancel', + delete: 'Delete', + saveChanges: 'Save changes', + actionFailed: 'Action failed', + condition: { + title: 'Visibility condition', + badge: 'conditional', + enable: 'Only apply when a condition holds', + field: 'Field path', + fieldHelp: 'Dot path into the form, e.g. sectionKey.fieldKey', + operator: 'Operator', + value: 'Value', + values: 'Any of', + removeValue: 'Click to remove', + isSet: 'is set', + isNotSet: 'is not set', + unknownField: 'This path is not a field in the current schema yet — it will still be saved as typed.', + }, + schema: { + subtitle: + 'Sections and fields the applicant sees for this licence type. Sections sharing a group render together on one wizard step.', + checkErrors: 'Check for errors', + noIssues: 'No issues found', + issuesFound: 'Issues found', + save: 'Save schema', + saved: 'Form schema saved', + empty: 'No sections yet', + emptyBody: "Add a section to start building this licence type's form.", + }, + section: { + add: 'Add section', + edit: 'Edit section', + delete: 'Delete section', + deleteConfirm: 'Remove "{{name}}" and all of its fields from this schema?', + key: 'Section key', + keyHelp: 'Letters, numbers and underscores only', + keyLocked: 'Key cannot change once created', + keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores', + title: 'Title', + description: 'Description', + group: 'Wizard step group', + groupHelp: 'Sections sharing the same group render together on one step', + groupBadge: 'group', + groupOrder: 'Group order', + sortOrder: 'Sort order', + }, + field: { + add: 'Add field', + edit: 'Edit field', + delete: 'Delete field', + deleteConfirm: 'Remove "{{name}}" from this section?', + key: 'Field key', + keyHelp: 'Letters, numbers and underscores only — becomes the form data key', + keyLocked: 'Key cannot change once created', + keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores', + label: 'Label', + type: 'Field type', + required: 'Required', + placeholder: 'Placeholder', + helpText: 'Help text', + min: 'Minimum', + max: 'Maximum', + maxLength: 'Max length', + options: 'Options', + addOption: 'Add option', + optionValue: 'Value', + optionLabel: 'Label', + }, + doc: { + subtitle: 'What an applicant must upload for this licence type, split by new application and renewal.', + add: 'Add document requirement', + edit: 'Edit document requirement', + delete: 'Delete document requirement', + deleteWarning: 'Applicants already relying on this slot will no longer see it. This cannot be undone.', + deleteConfirm: 'Remove "{{name}}"?', + created: 'Document requirement added', + updated: 'Document requirement updated', + deleted: 'Document requirement removed', + loading: 'Loading document requirements…', + loadFailed: 'Could not load document requirements', + empty: 'No document requirements configured', + emptyBody: 'Add the documents an applicant must upload for this licence type.', + emptyKind: 'No document requirements for this application kind yet.', + key: 'Key', + keyHelp: 'Stable slug identifying this document slot', + keyLocked: 'Key cannot change once created', + keyRequired: 'Key is required', + name: 'Name', + description: 'Description', + applicationKind: 'Application kind', + kindNew: 'New application', + kindRenewal: 'Renewal', + kindLocked: 'Application kind cannot change once created', + mode: 'Mode', + modeAlways: 'Always required', + modeConditional: 'Required when condition holds', + modeOptional: 'Optional upload', + conditionRequired: 'A conditional requirement needs a condition', + allowedTypes: 'Allowed file types', + maxSize: 'Max file size (MB)', + requiresValidity: 'Requires validity dates', + allowMultiple: 'Allow multiple uploads', + multiple: 'multiple', + sortOrder: 'Sort order', + when: 'when', + }, + }, + seafarerRegistry: { title: 'Seafarer registry', profileCount_one: '{{count}} profile', diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index e7ac31ff9..3878efa3f 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -4,6 +4,7 @@ import { IconBook2, IconChartBar, IconClipboardList, + IconClipboardText, IconCreditCard, IconFileDescription, IconFilePlus, @@ -144,6 +145,12 @@ export const NAV_SECTIONS: NavSection[] = [ icon: IconRosetteDiscountCheck, permissions: [P.VIEW_TEMPLATES], }, + { + to: '/certificate-requirements', + label: 'nav.certificateRequirements', + icon: IconClipboardText, + permissions: [P.VIEW_LICENSE_TYPES], + }, { to: '/payment-config', label: 'nav.paymentConfig', diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 3635f5745..8019270ed 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -46,6 +46,7 @@ import { LicenseRegisterPage } from '../features/license-register/pages/LicenseR import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage'; import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage'; import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage'; +import { CertificateRequirementsPage } from '../features/certificate-requirements/pages/CertificateRequirementsPage'; /** Any-of gate shared by every licence-type queue and its review workspace. */ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS]; @@ -114,6 +115,8 @@ const router = createBrowserRouter([ { path: 'vessel-ownership-transfer/:id', element: }, // Config-driven review workspace, shared by every licence type. { path: 'certificate-designer', element: guard([P.VIEW_TEMPLATES], ) }, + // Form schema + document requirement authoring, shared by every licence type. + { path: 'certificate-requirements', element: guard([P.VIEW_LICENSE_TYPES], ) }, { path: 'licence-review', element: guard(APPLICATION_QUEUE, ) }, { path: 'licence-register', element: guard([P.VIEW_LICENSES], ) }, // Deep link into the grid with the type facet pinned, so "Freight diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 996f9d378..ac0fa0b02 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -6,6 +6,9 @@ import type { ApplicationPayment, ApplicationStaff, Attachment, + DocumentRequirement, + FormSchemaPalette, + FormSectionConfig, InitiatePaymentResult, IssuedLicense, Inspection, @@ -25,6 +28,7 @@ import type { QueueFilter, RemarkTargetType, SavedQueueView, + SchemaIssue, TemplateFieldPlacement, TemplateLogoPlacement, TemplatePageOptions, @@ -66,6 +70,7 @@ const TAGS = [ 'License', 'SavedView', 'LicenseTemplate', + 'DocumentRequirement', ] as const; const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const; @@ -178,6 +183,76 @@ export const licensingApi = baseApi providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)], }), + // ------------------------------------------------ form-schema builder + /** Replaces a licence type's form schema. Server re-validates on save. */ + updateFormSchema: builder.mutation< + LicenseType, + { id: string; formSchema: { sections: FormSectionConfig[] } } + >({ + query: ({ id, formSchema }) => ({ + url: `/license-types/${id}/form-schema`, + method: 'PUT', + body: { formSchema }, + }), + invalidatesTags: (_r, error, { id }) => + error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')], + }), + + /** Dry-run lint, for inline feedback while the schema is being edited. */ + validateFormSchema: builder.mutation< + { valid: boolean; issues: SchemaIssue[] }, + { formSchema: { sections: FormSectionConfig[] }; licenseTypeId?: string } + >({ + query: (body) => ({ + url: '/license-types/form-schema/validate', + method: 'POST', + body, + }), + }), + + /** Field types, condition operators and prefill sources the builder may offer. */ + getFormSchemaPalette: builder.query({ + query: () => ({ url: '/license-types/form-schema/palette' }), + }), + + // ------------------------------------------------- document requirements + /** + * Every document requirement, for the admin editor to filter by licence + * type client-side. The collection-query `q` filter syntax (`w=column:op: + * value`) has no typed builder on this side, and the table is small + * configuration data with no pagination need — see `licenseTypeId` usage + * at the call site. + */ + getDocumentRequirements: builder.query, void>({ + query: () => ({ url: '/document-requirements' }), + providesTags: () => [listTag('DocumentRequirement')], + }), + + createDocumentRequirement: builder.mutation< + DocumentRequirement, + Partial & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] } + >({ + query: (body) => ({ url: '/document-requirements', method: 'POST', body }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]), + }), + + updateDocumentRequirement: builder.mutation< + DocumentRequirement, + { id: string } & Partial + >({ + query: ({ id, ...body }) => ({ + url: `/document-requirements/${id}`, + method: 'PUT', + body, + }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]), + }), + + deleteDocumentRequirement: builder.mutation({ + query: (id) => ({ url: `/document-requirements/${id}`, method: 'DELETE' }), + invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]), + }), + // -------------------------------------------------------- application createApplication: builder.mutation< LicenseApplication, @@ -827,6 +902,13 @@ export const { useGetLicenseTypesQuery, useGetLicenseCategoriesQuery, useUpdateLicenseFeesMutation, + useUpdateFormSchemaMutation, + useValidateFormSchemaMutation, + useGetFormSchemaPaletteQuery, + useGetDocumentRequirementsQuery, + useCreateDocumentRequirementMutation, + useUpdateDocumentRequirementMutation, + useDeleteDocumentRequirementMutation, useUpdateLicenseValidityMutation, useGetLicenseTypeRequirementsQuery, useCreateApplicationMutation, diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 324b628d6..029f45f92 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -79,10 +79,12 @@ export interface FormFieldConfig { label: Bilingual; type: FormFieldType; required?: boolean; + placeholder?: Bilingual; helpText?: Bilingual; options?: { value: string; label: Bilingual }[]; min?: number; max?: number; + maxLength?: number; showWhen?: FieldCondition; readOnly?: boolean; source?: string; @@ -235,6 +237,7 @@ export interface StcwCapacityRow { export interface DocumentRequirement { id: string; + licenseTypeId: string; key: string; name: Bilingual; description?: Bilingual; @@ -244,7 +247,32 @@ export interface DocumentRequirement { allowedMimeTypes: string[]; maxSizeMb: number; requiresValidityDates: boolean; + allowMultiple: boolean; sortOrder: number; + isActive: boolean; +} + +/** + * What the form-schema builder may put on a form — the field types the engine + * understands, which constraints each one honours, the condition operators it + * supports, and the prefill sources a read-only field may draw from. Drives + * the builder's pickers so they never hardcode a list the server already owns. + */ +export interface FormSchemaPalette { + fieldTypes: { + type: FormFieldType; + supportsOptions: boolean; + supportsRange: boolean; + supportsMaxLength: boolean; + }[]; + conditionOperators: ("equals" | "notEquals" | "in" | "isSet")[]; + prefillSources: string[]; +} + +/** One problem the server's form-schema lint found. */ +export interface SchemaIssue { + path: string; + message: string; } export interface StaffEvidenceRequirement {