From 2af9e608004f3b11c483ac1df1b4c3b3cdcb4309 Mon Sep 17 00:00:00 2001 From: Nati Date: Thu, 13 Aug 2026 09:11:50 +0000 Subject: [PATCH] feat(vessel-registration): add vessel registration page with incident reporting and certificate download functionality feat(waiver): implement waiver page with application tracking and letter download feature feat(ui): introduce AdvancedTable component for enhanced table functionality across the application chore: update package-lock.json to remove unnecessary dependencies --- .../pages/CertificationPage/actions.tsx | 29 ++ .../pages/CertificationPage/columns.tsx | 31 ++ .../index.tsx} | 68 +-- .../pages/ConfigurationPage/actions.tsx | 29 ++ .../pages/ConfigurationPage/columns.tsx | 30 ++ .../index.tsx} | 69 +-- .../dashboard/pages/DashboardPage/columns.tsx | 34 ++ .../index.tsx} | 63 +-- .../ExamCandidatesPanel/columns.tsx | 96 ++++ .../index.tsx} | 114 +---- .../components/ExamIncidentsPanel/columns.tsx | 87 ++++ .../index.tsx} | 104 +--- .../features/exam/pages/ExamPage/actions.tsx | 29 ++ .../features/exam/pages/ExamPage/columns.tsx | 91 ++++ .../{ExamPage.tsx => ExamPage/index.tsx} | 95 +--- .../features/item/components/ItemTable.tsx | 60 --- .../item/components/ItemTable/actions.tsx | 17 + .../item/components/ItemTable/columns.tsx | 23 + .../item/components/ItemTable/index.tsx | 30 ++ .../pages/LicenseRegisterPage/columns.tsx | 104 ++++ .../index.tsx} | 127 +---- .../pages/LicenseQueuePage/columns.tsx | 106 ++++ .../index.tsx} | 270 ++-------- .../pages/LicenseReviewPage/columns.tsx | 80 +++ .../index.tsx} | 106 +--- .../components/LocationTypeModal/actions.tsx | 29 ++ .../components/LocationTypeModal/columns.tsx | 35 ++ .../index.tsx} | 82 +-- .../LogisticsHeadDashboardPage/columns.tsx | 39 ++ .../index.tsx} | 58 +-- .../pages/MedicalVerificationPage.tsx | 368 -------------- .../pages/MedicalVerificationPage/columns.tsx | 159 ++++++ .../pages/MedicalVerificationPage/index.tsx | 225 +++++++++ .../pages/PaymentConfigPage/columns.tsx | 99 ++++ .../index.tsx} | 98 +--- .../question/pages/QuestionPage/columns.tsx | 116 +++++ .../index.tsx} | 132 +---- .../components/RecordResultModal/columns.tsx | 55 ++ .../index.tsx} | 58 +-- .../result/pages/ExamAppealsPage/columns.tsx | 77 +++ .../index.tsx} | 93 +--- .../result/pages/ResultPage/columns.tsx | 221 ++++++++ .../{ResultPage.tsx => ResultPage/index.tsx} | 219 ++------ .../pages/SeafarerRegistryPage.tsx | 475 ------------------ .../pages/SeafarerRegistryPage/columns.tsx | 190 +++++++ .../pages/SeafarerRegistryPage/index.tsx | 295 +++++++++++ .../VesselRegistrationQueuePage/columns.tsx | 93 ++++ .../index.tsx} | 116 +---- .../pages/CertificatesPage/columns.tsx | 62 +++ .../index.tsx} | 71 +-- .../dashboard/pages/DashboardPage/columns.tsx | 56 +++ .../index.tsx} | 84 +--- .../exams/pages/ExamsPage/columns.tsx | 152 ++++++ .../{ExamsPage.tsx => ExamsPage/index.tsx} | 186 ++----- .../licensing/pages/MyApplicationsPage.tsx | 322 ------------ .../pages/MyApplicationsPage/columns.tsx | 176 +++++++ .../pages/MyApplicationsPage/index.tsx | 193 +++++++ .../pages/MySeaRecordsPage/actions.tsx | 66 +++ .../pages/MySeaRecordsPage/columns.tsx | 104 ++++ .../index.tsx} | 256 ++-------- .../pages/VesselRegistrationPage/columns.tsx | 145 ++++++ .../index.tsx} | 147 +----- .../waiver/pages/WaiverPage/columns.tsx | 57 +++ .../{WaiverPage.tsx => WaiverPage/index.tsx} | 74 +-- libs/ui/src/index.ts | 1 + libs/ui/src/lib/table/AdvancedTable.tsx | 261 ++++++++++ package-lock.json | 18 +- 67 files changed, 4209 insertions(+), 3446 deletions(-) create mode 100644 apps/backoffice/src/app/features/certification/pages/CertificationPage/actions.tsx create mode 100644 apps/backoffice/src/app/features/certification/pages/CertificationPage/columns.tsx rename apps/backoffice/src/app/features/certification/pages/{CertificationPage.tsx => CertificationPage/index.tsx} (71%) create mode 100644 apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/actions.tsx create mode 100644 apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/columns.tsx rename apps/backoffice/src/app/features/configuration/pages/{ConfigurationPage.tsx => ConfigurationPage/index.tsx} (80%) create mode 100644 apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx rename apps/backoffice/src/app/features/dashboard/pages/{DashboardPage.tsx => DashboardPage/index.tsx} (60%) create mode 100644 apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx rename apps/backoffice/src/app/features/exam/components/{ExamCandidatesPanel.tsx => ExamCandidatesPanel/index.tsx} (50%) create mode 100644 apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx rename apps/backoffice/src/app/features/exam/components/{ExamIncidentsPanel.tsx => ExamIncidentsPanel/index.tsx} (67%) create mode 100644 apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx create mode 100644 apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx rename apps/backoffice/src/app/features/exam/pages/{ExamPage.tsx => ExamPage/index.tsx} (77%) delete mode 100644 apps/backoffice/src/app/features/item/components/ItemTable.tsx create mode 100644 apps/backoffice/src/app/features/item/components/ItemTable/actions.tsx create mode 100644 apps/backoffice/src/app/features/item/components/ItemTable/columns.tsx create mode 100644 apps/backoffice/src/app/features/item/components/ItemTable/index.tsx create mode 100644 apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx rename apps/backoffice/src/app/features/license-register/pages/{LicenseRegisterPage.tsx => LicenseRegisterPage/index.tsx} (56%) create mode 100644 apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx rename apps/backoffice/src/app/features/license-review/pages/{LicenseQueuePage.tsx => LicenseQueuePage/index.tsx} (69%) create mode 100644 apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/columns.tsx rename apps/backoffice/src/app/features/license-review/pages/{LicenseReviewPage.tsx => LicenseReviewPage/index.tsx} (90%) create mode 100644 apps/backoffice/src/app/features/location/components/LocationTypeModal/actions.tsx create mode 100644 apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx rename apps/backoffice/src/app/features/location/components/{LocationTypeModal.tsx => LocationTypeModal/index.tsx} (67%) create mode 100644 apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx rename apps/backoffice/src/app/features/logistics-head/pages/{LogisticsHeadDashboardPage.tsx => LogisticsHeadDashboardPage/index.tsx} (71%) delete mode 100644 apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx create mode 100644 apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx create mode 100644 apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx create mode 100644 apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/columns.tsx rename apps/backoffice/src/app/features/payment-config/pages/{PaymentConfigPage.tsx => PaymentConfigPage/index.tsx} (70%) create mode 100644 apps/backoffice/src/app/features/question/pages/QuestionPage/columns.tsx rename apps/backoffice/src/app/features/question/pages/{QuestionPage.tsx => QuestionPage/index.tsx} (69%) create mode 100644 apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx rename apps/backoffice/src/app/features/result/components/{RecordResultModal.tsx => RecordResultModal/index.tsx} (79%) create mode 100644 apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx rename apps/backoffice/src/app/features/result/pages/{ExamAppealsPage.tsx => ExamAppealsPage/index.tsx} (54%) create mode 100644 apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx rename apps/backoffice/src/app/features/result/pages/{ResultPage.tsx => ResultPage/index.tsx} (70%) delete mode 100644 apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx create mode 100644 apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/columns.tsx create mode 100644 apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/index.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx rename apps/backoffice/src/app/features/vessel-registration/pages/{VesselRegistrationQueuePage.tsx => VesselRegistrationQueuePage/index.tsx} (69%) create mode 100644 apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx rename apps/portal/src/app/features/certificates/pages/{CertificatesPage.tsx => CertificatesPage/index.tsx} (74%) create mode 100644 apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx rename apps/portal/src/app/features/dashboard/pages/{DashboardPage.tsx => DashboardPage/index.tsx} (88%) create mode 100644 apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx rename apps/portal/src/app/features/exams/pages/{ExamsPage.tsx => ExamsPage/index.tsx} (54%) delete mode 100644 apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx create mode 100644 apps/portal/src/app/features/licensing/pages/MyApplicationsPage/columns.tsx create mode 100644 apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx create mode 100644 apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/actions.tsx create mode 100644 apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx rename apps/portal/src/app/features/seafarer/pages/{MySeaRecordsPage.tsx => MySeaRecordsPage/index.tsx} (65%) create mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx rename apps/portal/src/app/features/vessel-registration/pages/{VesselRegistrationPage.tsx => VesselRegistrationPage/index.tsx} (62%) create mode 100644 apps/portal/src/app/features/waiver/pages/WaiverPage/columns.tsx rename apps/portal/src/app/features/waiver/pages/{WaiverPage.tsx => WaiverPage/index.tsx} (68%) create mode 100644 libs/ui/src/lib/table/AdvancedTable.tsx diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage/actions.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/actions.tsx new file mode 100644 index 000000000..ae9db2206 --- /dev/null +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/actions.tsx @@ -0,0 +1,29 @@ +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableAction } from '@ema-platform/ui'; +import type { Certification } from '../../types/certification'; + +export function certificationColumnActions( + t: TFunction, + handlers: { + onEdit: (cert: Certification) => void; + onDelete: (cert: Certification) => void; + }, +): AdvancedTableAction[] { + return [ + { + key: 'edit', + label: t('certification.update'), + color: 'blue', + icon: , + onClick: handlers.onEdit, + }, + { + key: 'delete', + label: t('certification.delete'), + color: 'red', + icon: , + onClick: handlers.onDelete, + }, + ]; +} diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage/columns.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/columns.tsx new file mode 100644 index 000000000..2ac23618e --- /dev/null +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/columns.tsx @@ -0,0 +1,31 @@ +import { Badge, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import type { Certification } from '../../types/certification'; + +export function certificationColumns( + t: TFunction, + locale: 'en' | 'am', +): AdvancedTableColumn[] { + return [ + { + key: 'name', + header: t('certification.columns.name'), + render: (cert) => {cert.name[locale]}, + }, + { + key: 'description', + header: t('certification.columns.description'), + render: (cert) => {cert.description[locale]}, + }, + { + key: 'status', + header: t('certification.columns.status'), + render: (cert) => ( + + {cert.isActive ? t('certification.status.active') : t('certification.status.inactive')} + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx similarity index 71% rename from apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx rename to apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx index d59296ad9..35135c6bf 100644 --- a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx @@ -4,9 +4,6 @@ import { Title, Group, Button, - Table, - Badge, - ActionIcon, Modal, Text, TextInput, @@ -18,15 +15,17 @@ import { } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; -import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; +import { AdvancedTable, notify } from '@ema-platform/ui'; +import { certificationColumns } from './columns'; +import { certificationColumnActions } from './actions'; import { useGetCertificationsQuery, useCreateCertificationMutation, useUpdateCertificationMutation, useDeleteCertificationMutation, -} from '../api/certification-api'; -import type { Certification } from '../types/certification'; +} from '../../api/certification-api'; +import type { Certification } from '../../types/certification'; function CertificationForm({ editing, @@ -75,7 +74,7 @@ function CertificationForm({ export function CertificationPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; - const { data, isLoading, isError } = useGetCertificationsQuery(); + const { data, isLoading, isError, refetch } = useGetCertificationsQuery(); const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation(); const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation(); const [deleteCert] = useDeleteCertificationMutation(); @@ -148,48 +147,17 @@ export function CertificationPage() { )} - - - - {t('certification.columns.name')} - {t('certification.columns.description')} - {t('certification.columns.status')} - - - - - {certifications.map((cert) => ( - - {cert.name[locale]} - - {cert.description[locale]} - - - - {cert.isActive ? t('certification.status.active') : t('certification.status.inactive')} - - - - - { setEditing(cert); setShowForm(true); }}> - - - { setDeleteTarget(cert); openDelete(); }}> - - - - - - ))} - {certifications.length === 0 && ( - - - {t('certification.noItems')} - - - )} - -
+ cert.id} + actions={certificationColumnActions(t, { + onEdit: (cert) => { setEditing(cert); setShowForm(true); }, + onDelete: (cert) => { setDeleteTarget(cert); openDelete(); }, + })} + onRefresh={refetch} + emptyTitle={t('certification.noItems')} + />
diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/actions.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/actions.tsx new file mode 100644 index 000000000..f0be394a4 --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/actions.tsx @@ -0,0 +1,29 @@ +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableAction } from '@ema-platform/ui'; +import type { Profession } from '../../types/configuration'; + +export function professionColumnActions( + t: TFunction, + handlers: { + onEdit: (prof: Profession) => void; + onDelete: (prof: Profession) => void; + }, +): AdvancedTableAction[] { + return [ + { + key: 'edit', + label: t('configuration.edit'), + color: 'blue', + icon: , + onClick: handlers.onEdit, + }, + { + key: 'delete', + label: t('configuration.delete'), + color: 'red', + icon: , + onClick: handlers.onDelete, + }, + ]; +} diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/columns.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/columns.tsx new file mode 100644 index 000000000..c38b23c04 --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/columns.tsx @@ -0,0 +1,30 @@ +import { Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import type { Profession } from '../../types/configuration'; + +export function professionColumns( + t: TFunction, + locale: 'en' | 'am', + getDeptName: (deptId: string) => string, +): AdvancedTableColumn[] { + return [ + { + key: 'name', + header: t('configuration.name'), + render: (prof) => prof.name[locale], + }, + { + key: 'description', + header: t('configuration.description'), + render: (prof) => ( + {prof.description[locale]} + ), + }, + { + key: 'department', + header: t('configuration.department'), + render: (prof) => getDeptName(prof.departmentId), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx similarity index 80% rename from apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx rename to apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx index 17902ca67..4ed445d7b 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx @@ -7,8 +7,6 @@ import { Button, TextInput, Textarea, - Table, - ActionIcon, Modal, Text, Select, @@ -19,19 +17,21 @@ import { } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; -import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react'; +import { IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { notify } from '@ema-platform/ui'; -import { LocationPage } from '../../location/pages/LocationPage'; -import { CertificationPage } from '../../certification/pages/CertificationPage'; +import { AdvancedTable, notify } from '@ema-platform/ui'; +import { professionColumns } from './columns'; +import { professionColumnActions } from './actions'; +import { LocationPage } from '../../../location/pages/LocationPage'; +import { CertificationPage } from '../../../certification/pages/CertificationPage'; import { useGetOrganizationsQuery, useGetProfessionsQuery, useCreateProfessionMutation, useUpdateProfessionMutation, useDeleteProfessionMutation, -} from '../api/configuration-api'; -import type { Profession } from '../types/configuration'; +} from '../../api/configuration-api'; +import type { Profession } from '../../types/configuration'; interface ProfFormValues { nameEn: string; @@ -132,7 +132,7 @@ function ProfessionTab() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; const { data: deptRes } = useGetOrganizationsQuery(); - const { data: profRes, isLoading, isError } = useGetProfessionsQuery(); + const { data: profRes, isLoading, isError, refetch } = useGetProfessionsQuery(); const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation(); const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation(); const [deleteProfession] = useDeleteProfessionMutation(); @@ -243,46 +243,17 @@ function ProfessionTab() { /> )} - - - - {t('configuration.name')} - {t('configuration.description')} - {t('configuration.department')} - - - - - {professions.filter((p) => p.isActive).map((prof) => ( - - {prof.name[locale]} - - {prof.description[locale]} - - {getDeptName(prof.departmentId)} - - - handleEditProf(prof)}> - - - handleDeleteProf(prof)}> - - - - - - ))} - {professions.length === 0 && ( - - - - {t('configuration.noProfessions')} - - - - )} - -
+ p.isActive)} + rowKey={(prof) => prof.id} + actions={professionColumnActions(t, { + onEdit: handleEditProf, + onDelete: handleDeleteProf, + })} + onRefresh={refetch} + emptyTitle={t('configuration.noProfessions')} + /> diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx new file mode 100644 index 000000000..a3c65aaca --- /dev/null +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/columns.tsx @@ -0,0 +1,34 @@ +import { Badge, Text } from '@mantine/core'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import { + STATUS_COLORS, + STATUS_LABELS, + type LicenseApplication, + type LicenseStatus, +} from '@ema-platform/api'; + +export const dashboardQueueColumns: AdvancedTableColumn[] = [ + { + key: 'applicationNumber', + header: 'Number', + render: (app) => ( + + {app.applicationNumber} + + ), + }, + { + key: 'companyName', + header: 'Company', + render: (app) => {app.companyName ?? '—'}, + }, + { + key: 'status', + header: 'Status', + render: (app) => ( + + {STATUS_LABELS[app.status as LicenseStatus]} + + ), + }, +]; diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx similarity index 60% rename from apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx rename to apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx index 3afa394df..cae9a77f8 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage/index.tsx @@ -1,24 +1,18 @@ import { useNavigate } from 'react-router-dom'; import { - Badge, Card, Center, Container, Group, Loader, SimpleGrid, - Table, Text, Title, } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; -import { - STATUS_COLORS, - STATUS_LABELS, - useGetAssignedToMeQuery, - useGetQueueQuery, - type LicenseStatus, -} from '@ema-platform/api'; +import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api'; +import { AdvancedTable } from '@ema-platform/ui'; +import { dashboardQueueColumns } from './columns'; /** * Backoffice home. @@ -95,49 +89,14 @@ export function DashboardPage() { Open queue - {unclaimed.length === 0 ? ( -
- - Nothing waiting to be claimed. - -
- ) : ( - - - - Number - Company - Status - - - - {unclaimed.slice(0, 8).map((app) => ( - navigate('/licence-review')} - > - - - {app.applicationNumber} - - - - {app.companyName ?? '—'} - - - - {STATUS_LABELS[app.status as LicenseStatus]} - - - - ))} - -
- )} + app.id} + onRowClick={() => navigate('/licence-review')} + onRefresh={queue.refetch} + emptyTitle="Nothing waiting to be claimed." + /> ); diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx new file mode 100644 index 000000000..09629e89f --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx @@ -0,0 +1,96 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconUserCheck } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; + +const ATTENDANCE_COLOR: Record = { + REGISTERED: 'gray', + PRESENT: 'teal', + LATE: 'yellow', + ABSENT: 'red', + WITHDRAWN: 'orange', + DISQUALIFIED: 'red', +}; + +export const candidateName = (registration: ExamRegistration) => + [ + registration.profile?.firstName, + registration.profile?.middleName, + registration.profile?.lastName, + ] + .filter(Boolean) + .join(' ') || registration.profileId.slice(0, 8); + +export function examCandidateColumns( + t: TFunction, + handlers: { onRecord: (registration: ExamRegistration) => void }, +): AdvancedTableColumn[] { + return [ + { + key: 'admission', + header: t('exam.candidates.admission'), + render: (registration) => ( + + {registration.admissionNumber} + + ), + }, + { + key: 'name', + header: t('exam.candidates.name'), + render: (registration) => {candidateName(registration)}, + }, + { + key: 'attempt', + header: t('exam.candidates.attempt'), + render: (registration) => ( + + {registration.kind === 'RETAKE' + ? t('exam.candidates.retake', { n: registration.attemptNumber }) + : t('exam.candidates.firstSitting')} + + ), + }, + { + key: 'attendance', + header: t('exam.candidates.attendance'), + render: (registration) => ( + + {t(`exam.attendance.${registration.attendanceStatus}`)} + + ), + }, + { + key: 'remark', + header: t('exam.candidates.remark'), + render: (registration) => ( + + {registration.attendanceRemark ?? '—'} + + ), + }, + { + key: 'record', + header: '', + render: (registration) => ( + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx similarity index 50% rename from apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel.tsx rename to apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx index 3660b77c9..0a0c88ac6 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/index.tsx @@ -2,35 +2,25 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, - Badge, Button, Group, Modal, Paper, Select, Stack, - Table, Text, Textarea, Title, } from '@mantine/core'; -import { IconInfoCircle, IconUserCheck } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { IconInfoCircle } from '@tabler/icons-react'; +import { AdvancedTable, notify } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; import { useGetExamRegistrationsQuery, useRecordAttendanceMutation, -} from '../api/exam-api'; -import type { AttendanceStatus, ExamRegistration } from '../types/exam'; - -const ATTENDANCE_COLOR: Record = { - REGISTERED: 'gray', - PRESENT: 'teal', - LATE: 'yellow', - ABSENT: 'red', - WITHDRAWN: 'orange', - DISQUALIFIED: 'red', -}; +} from '../../api/exam-api'; +import type { AttendanceStatus, ExamRegistration } from '../../types/exam'; +import { candidateName, examCandidateColumns } from './columns'; /** Rulings that end the sitting, and so must be explained (US-EXAM-009). */ const NEEDS_REMARK: AttendanceStatus[] = ['WITHDRAWN', 'DISQUALIFIED']; @@ -51,20 +41,21 @@ const OPTIONS: AttendanceStatus[] = [ */ export function ExamCandidatesPanel({ examId }: { examId: string }) { const { t } = useTranslation(); - const { data: registrations, isError } = useGetExamRegistrationsQuery(examId); + const { data: registrations, isError, refetch } = useGetExamRegistrationsQuery(examId); const [recordAttendance, { isLoading }] = useRecordAttendanceMutation(); const [target, setTarget] = useState(null); const [status, setStatus] = useState('PRESENT'); const [remark, setRemark] = useState(''); - const candidateName = (registration: ExamRegistration) => - [ - registration.profile?.firstName, - registration.profile?.middleName, - registration.profile?.lastName, - ] - .filter(Boolean) - .join(' ') || registration.profileId.slice(0, 8); + const startRecording = (registration: ExamRegistration) => { + setTarget(registration); + setStatus( + registration.attendanceStatus === 'REGISTERED' + ? 'PRESENT' + : registration.attendanceStatus, + ); + setRemark(registration.attendanceRemark ?? ''); + }; const save = async () => { if (!target) return; @@ -100,75 +91,12 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) { {t('exam.candidates.none')} ) : ( - - - - {t('exam.candidates.admission')} - {t('exam.candidates.name')} - {t('exam.candidates.attempt')} - {t('exam.candidates.attendance')} - {t('exam.candidates.remark')} - - - - - {(registrations ?? []).map((registration) => ( - - - - {registration.admissionNumber} - - - - {candidateName(registration)} - - - - {registration.kind === 'RETAKE' - ? t('exam.candidates.retake', { n: registration.attemptNumber }) - : t('exam.candidates.firstSitting')} - - - - - {t(`exam.attendance.${registration.attendanceStatus}`)} - - - - - {registration.attendanceRemark ?? '—'} - - - - - - - ))} - -
+ registration.id} + onRefresh={refetch} + /> )} = { + OPEN: 'red', + UNDER_REVIEW: 'yellow', + RESOLVED: 'teal', + DISMISSED: 'gray', +}; + +export function examIncidentColumns( + t: TFunction, + handlers: { onResolve: (incident: ExamIncident) => void }, +): AdvancedTableColumn[] { + return [ + { + key: 'type', + header: t('exam.incidents.type'), + render: (incident) => ( + + {t(`exam.incidentType.${incident.type}`)} + + ), + }, + { + key: 'candidate', + header: t('exam.incidents.candidate'), + render: (incident) => ( + + {incident.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')} + + ), + }, + { + key: 'description', + header: t('exam.incidents.description'), + render: (incident) => ( + <> + + {incident.description} + + {incident.resolution && ( + + ⤷ {incident.resolution} + + )} + + ), + }, + { + key: 'occurred', + header: t('exam.incidents.occurred'), + render: (incident) => {incident.occurredAt?.slice(0, 10)}, + }, + { + key: 'status', + header: t('exam.incidents.status'), + render: (incident) => ( + + {t(`exam.incidentStatus.${incident.status}`)} + + ), + }, + { + key: 'resolve', + header: '', + render: (incident) => + (incident.status === 'OPEN' || incident.status === 'UNDER_REVIEW') && ( + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx similarity index 67% rename from apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx rename to apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx index aa93be2e1..579d54d14 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx @@ -2,32 +2,27 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, - Badge, Button, Group, Modal, Paper, Select, Stack, - Table, Text, Textarea, Title, } from '@mantine/core'; -import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { IconInfoCircle, IconPlus } from '@tabler/icons-react'; +import { AdvancedTable, notify } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; import { useGetExamIncidentsQuery, useGetExamRegistrationsQuery, useRecordIncidentMutation, useResolveIncidentMutation, -} from '../api/exam-api'; -import type { - ExamIncident, - ExamIncidentStatus, - ExamIncidentType, -} from '../types/exam'; +} from '../../api/exam-api'; +import type { ExamIncident, ExamIncidentType } from '../../types/exam'; +import { examIncidentColumns } from './columns'; const TYPES: ExamIncidentType[] = [ 'MISCONDUCT', @@ -37,13 +32,6 @@ const TYPES: ExamIncidentType[] = [ 'OTHER', ]; -const STATUS_COLOR: Record = { - OPEN: 'red', - UNDER_REVIEW: 'yellow', - RESOLVED: 'teal', - DISMISSED: 'gray', -}; - /** * The session incident log (US-EXAM-010). Invigilators file entries during the * sitting; a supervisor closes each one with a written ruling that stays on @@ -51,7 +39,7 @@ const STATUS_COLOR: Record = { */ export function ExamIncidentsPanel({ examId }: { examId: string }) { const { t } = useTranslation(); - const { data: incidents, isError } = useGetExamIncidentsQuery(examId); + const { data: incidents, isError, refetch } = useGetExamIncidentsQuery(examId); const { data: registrations } = useGetExamRegistrationsQuery(examId); const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation(); const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation(); @@ -96,6 +84,12 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) { } }; + const startResolve = (incident: ExamIncident) => { + setResolveTarget(incident); + setOutcome('RESOLVED'); + setResolution(''); + }; + const close = async () => { if (!resolveTarget || !resolution.trim()) { notify.error(t('exam.incidents.resolution')); @@ -139,74 +133,12 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) { {t('exam.incidents.none')} ) : ( - - - - {t('exam.incidents.type')} - {t('exam.incidents.candidate')} - {t('exam.incidents.description')} - {t('exam.incidents.occurred')} - {t('exam.incidents.status')} - - - - - {(incidents ?? []).map((incident) => ( - - - - {t(`exam.incidentType.${incident.type}`)} - - - - - {incident.registration?.admissionNumber ?? - t('exam.incidents.wholeRoom')} - - - - - {incident.description} - - {incident.resolution && ( - - ⤷ {incident.resolution} - - )} - - - {incident.occurredAt?.slice(0, 10)} - - - - {t(`exam.incidentStatus.${incident.status}`)} - - - - {(incident.status === 'OPEN' || - incident.status === 'UNDER_REVIEW') && ( - - )} - - - ))} - -
+ incident.id} + onRefresh={refetch} + /> )} void; + onDelete: (exam: Exam) => void; + }, +): AdvancedTableAction[] { + return [ + { + key: 'edit', + label: t('exam.update'), + color: 'blue', + icon: , + onClick: handlers.onEdit, + }, + { + key: 'delete', + label: t('exam.delete'), + color: 'red', + icon: , + onClick: handlers.onDelete, + }, + ]; +} diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx new file mode 100644 index 000000000..a244c0056 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx @@ -0,0 +1,91 @@ +import { Badge, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import type { Exam } from '../../types/exam'; + +const STATUS_COLOR: Record = { + PENDING: 'gray', + ACTIVE: 'blue', + COMPLETED: 'teal', + CANCELLED: 'red', + POSTPONED: 'orange', + PUBLISHED: 'green', +}; + +export function examColumns( + t: TFunction, + handlers: { + locale: 'en' | 'am'; + getCertName: (id: string) => string; + onTitleClick: (exam: Exam) => void; + }, +): AdvancedTableColumn[] { + return [ + { + key: 'title', + header: t('exam.columns.title'), + render: (exam) => ( + handlers.onTitleClick(exam)} + > + {exam.title[handlers.locale]} + + ), + }, + { + key: 'certification', + header: t('exam.columns.certification'), + render: (exam) => {handlers.getCertName(exam.certificationId)}, + }, + { + key: 'date', + header: t('exam.columns.date'), + render: (exam) => {exam.date}, + }, + { + key: 'type', + header: t('exam.columns.type'), + render: (exam) => ( + + {t(`exam.type.${exam.type}`)} + + ), + }, + { + key: 'form', + header: t('exam.columns.form'), + render: (exam) => ( + + {t(`exam.formType.${exam.form}`)} + + ), + }, + { + key: 'venue', + header: t('exam.columns.venue'), + render: (exam) => {exam.venue}, + }, + { + key: 'questions', + header: t('exam.columns.questions'), + render: (exam) => ( + + {exam.questions?.length ?? 0} + + ), + }, + { + key: 'status', + header: t('exam.columns.status'), + render: (exam) => ( + + {t(`exam.status.${exam.status}`)} + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx similarity index 77% rename from apps/backoffice/src/app/features/exam/pages/ExamPage.tsx rename to apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index ab9404fe4..26d1c8598 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -5,9 +5,6 @@ import { Title, Group, Button, - Table, - Badge, - ActionIcon, Modal, Text, TextInput, @@ -24,25 +21,18 @@ import { } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; -import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; -import { useGetCertificationsQuery } from '../../certification/api/certification-api'; +import { IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react'; +import { AdvancedTable, notify } from '@ema-platform/ui'; +import { useGetCertificationsQuery } from '../../../certification/api/certification-api'; import { useGetExamsQuery, useCreateExamMutation, useUpdateExamMutation, useDeleteExamMutation, -} from '../api/exam-api'; -import type { Exam } from '../types/exam'; - -const STATUS_COLOR: Record = { - PENDING: 'gray', - ACTIVE: 'blue', - COMPLETED: 'teal', - CANCELLED: 'red', - POSTPONED: 'orange', - PUBLISHED: 'green', -}; +} from '../../api/exam-api'; +import type { Exam } from '../../types/exam'; +import { examColumns } from './columns'; +import { examColumnActions } from './actions'; function ExamForm({ editing, @@ -151,7 +141,7 @@ export function ExamPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; const { data: certRes } = useGetCertificationsQuery(); - const { data, isLoading, isError } = useGetExamsQuery(); + const { data, isLoading, isError, refetch } = useGetExamsQuery(); const [createExam, { isLoading: isCreating }] = useCreateExamMutation(); const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation(); const [deleteExam] = useDeleteExamMutation(); @@ -240,60 +230,21 @@ export function ExamPage() { )} - - - - {t('exam.columns.title')} - {t('exam.columns.certification')} - {t('exam.columns.date')} - {t('exam.columns.type')} - {t('exam.columns.form')} - {t('exam.columns.venue')} - {t('exam.columns.questions')} - {t('exam.columns.status')} - - - - - {exams.map((exam) => ( - - - navigate(`/exams/${exam.id}`)}> - {exam.title[locale]} - - - {getCertName(exam.certificationId)} - {exam.date} - {t(`exam.type.${exam.type}`)} - {t(`exam.formType.${exam.form}`)} - {exam.venue} - - {exam.questions?.length ?? 0} - - - {t(`exam.status.${exam.status}`)} - - - - { setEditing(exam); setShowForm(true); }}> - - - { setDeleteTarget(exam); openDelete(); }}> - - - - - - ))} - {exams.length === 0 && ( - - - {t('exam.noItems')} - - - )} - -
+ navigate(`/exams/${exam.id}`), + })} + data={exams} + rowKey={(exam) => exam.id} + actions={examColumnActions(t, { + onEdit: (exam) => { setEditing(exam); setShowForm(true); }, + onDelete: (exam) => { setDeleteTarget(exam); openDelete(); }, + })} + onRefresh={refetch} + emptyTitle={t('exam.noItems')} + />
{/* Delete confirmation */} diff --git a/apps/backoffice/src/app/features/item/components/ItemTable.tsx b/apps/backoffice/src/app/features/item/components/ItemTable.tsx deleted file mode 100644 index ca7fde9b2..000000000 --- a/apps/backoffice/src/app/features/item/components/ItemTable.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Table, Badge, ActionIcon, Text } from '@mantine/core'; -import { IconTrash } from '@tabler/icons-react'; -import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api'; -import { notify } from '@ema-platform/ui'; - -const STATUS_COLORS: Record = { - DRAFT: 'gray', - ACTIVE: 'green', - ARCHIVED: 'orange', -}; - -export function ItemTable() { - const { data, isLoading } = useGetItemsQuery({}); - const [deleteItem] = useDeleteItemMutation(); - - const handleDelete = async (id: string) => { - try { - await deleteItem(id).unwrap(); - notify.success('Item deleted'); - } catch { - notify.error('Failed to delete item'); - } - }; - - if (isLoading) return Loading...; - if (!data?.data.length) return No items found.; - - return ( - - - - Name - Status - Created - - - - - {data.data.map((item) => ( - - {item.name} - - {item.status} - - {new Date(item.createdAt).toLocaleDateString()} - - handleDelete(item.id)} - > - - - - - ))} - -
- ); -} diff --git a/apps/backoffice/src/app/features/item/components/ItemTable/actions.tsx b/apps/backoffice/src/app/features/item/components/ItemTable/actions.tsx new file mode 100644 index 000000000..3ff97abce --- /dev/null +++ b/apps/backoffice/src/app/features/item/components/ItemTable/actions.tsx @@ -0,0 +1,17 @@ +import { IconTrash } from '@tabler/icons-react'; +import type { AdvancedTableAction } from '@ema-platform/ui'; +import type { Item } from '../../api/item-api'; + +export function itemColumnActions(handlers: { + onDelete: (item: Item) => void; +}): AdvancedTableAction[] { + return [ + { + key: 'delete', + label: 'Delete', + color: 'red', + icon: , + onClick: handlers.onDelete, + }, + ]; +} diff --git a/apps/backoffice/src/app/features/item/components/ItemTable/columns.tsx b/apps/backoffice/src/app/features/item/components/ItemTable/columns.tsx new file mode 100644 index 000000000..36ba01d6d --- /dev/null +++ b/apps/backoffice/src/app/features/item/components/ItemTable/columns.tsx @@ -0,0 +1,23 @@ +import { Badge } from '@mantine/core'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import type { Item } from '../../api/item-api'; + +const STATUS_COLORS: Record = { + DRAFT: 'gray', + ACTIVE: 'green', + ARCHIVED: 'orange', +}; + +export const itemColumns: AdvancedTableColumn[] = [ + { key: 'name', header: 'Name' }, + { + key: 'status', + header: 'Status', + render: (item) => {item.status}, + }, + { + key: 'createdAt', + header: 'Created', + render: (item) => new Date(item.createdAt).toLocaleDateString(), + }, +]; diff --git a/apps/backoffice/src/app/features/item/components/ItemTable/index.tsx b/apps/backoffice/src/app/features/item/components/ItemTable/index.tsx new file mode 100644 index 000000000..c73db72a5 --- /dev/null +++ b/apps/backoffice/src/app/features/item/components/ItemTable/index.tsx @@ -0,0 +1,30 @@ +import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../../api/item-api'; +import { AdvancedTable, notify } from '@ema-platform/ui'; +import { itemColumns } from './columns'; +import { itemColumnActions } from './actions'; + +export function ItemTable() { + const { data, isLoading, refetch } = useGetItemsQuery({}); + const [deleteItem] = useDeleteItemMutation(); + + const handleDelete = async (item: Item) => { + try { + await deleteItem(item.id).unwrap(); + notify.success('Item deleted'); + } catch { + notify.error('Failed to delete item'); + } + }; + + return ( + item.id} + actions={itemColumnActions({ onDelete: handleDelete })} + loading={isLoading} + onRefresh={refetch} + emptyTitle="No items found" + /> + ); +} diff --git a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx new file mode 100644 index 000000000..1f7220933 --- /dev/null +++ b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx @@ -0,0 +1,104 @@ +import { Badge, Button, Text, Tooltip } from '@mantine/core'; +import { IconShieldCog } from '@tabler/icons-react'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import { localized } from '@ema-platform/api'; +import type { IssuedLicense } from '@ema-platform/api'; + +const LICENSE_STATUS_COLORS: Record = { + ACTIVE: 'green', + EXPIRED: 'yellow', + SUSPENDED: 'orange', + CANCELLED: 'red', + SUPERSEDED: 'gray', +}; + +export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate'; + +/** Which lifecycle actions make sense from each current status. */ +export function actionsFor(license: IssuedLicense): LifecycleAction[] { + switch (license.status) { + case 'ACTIVE': + case 'EXPIRED': + return ['suspend', 'revoke']; + case 'SUSPENDED': + return ['reinstate', 'revoke']; + default: + return []; + } +} + +export function licenseRegisterColumns(handlers: { + onStatus: (license: IssuedLicense) => void; +}): AdvancedTableColumn[] { + return [ + { + key: 'certificateNumber', + header: 'Certificate №', + render: (license) => ( + + {license.certificateNumber} + + ), + }, + { + key: 'type', + header: 'Type', + render: (license) => ( + {localized(license.licenseType?.name)} + ), + }, + { + key: 'holder', + header: 'Holder', + render: (license) => {license.companyName ?? '—'}, + }, + { + key: 'issued', + header: 'Issued', + render: (license) => ( + + {license.issueDate?.slice(0, 10)} + + ), + }, + { + key: 'expires', + header: 'Expires', + render: (license) => ( + + {license.expiryDate?.slice(0, 10)} + + ), + }, + { + key: 'status', + header: 'Status', + render: (license) => ( + + {license.status} + + ), + }, + { + key: 'lifecycle', + header: '', + render: (license) => + actionsFor(license).length > 0 ? ( + + + + ) : null, + }, + ]; +} diff --git a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage.tsx b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx similarity index 56% rename from apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage.tsx rename to apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx index 57965d1db..4d21f1b3b 100644 --- a/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage.tsx +++ b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/index.tsx @@ -1,43 +1,32 @@ import { useState } from 'react'; import { - Badge, Button, Card, - Center, Container, Group, - Loader, Modal, Select, Stack, - Table, Text, TextInput, Textarea, Title, - Tooltip, } from '@mantine/core'; -import { IconSearch, IconShieldCog } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { IconSearch } from '@tabler/icons-react'; +import { AdvancedTable, notify } from '@ema-platform/ui'; import { extractErrorMessage, - localized, useGetLicensesQuery, useReinstateLicenseMutation, useRevokeLicenseMutation, useSuspendLicenseMutation, } from '@ema-platform/api'; import type { IssuedLicense } from '@ema-platform/api'; - -const LICENSE_STATUS_COLORS: Record = { - ACTIVE: 'green', - EXPIRED: 'yellow', - SUSPENDED: 'orange', - CANCELLED: 'red', - SUPERSEDED: 'gray', -}; - -type LifecycleAction = 'suspend' | 'revoke' | 'reinstate'; +import { + actionsFor, + licenseRegisterColumns, + type LifecycleAction, +} from './columns'; const ACTIONS: Record< LifecycleAction, @@ -60,19 +49,6 @@ const ACTIONS: Record< }, }; -/** Which lifecycle actions make sense from each current status. */ -function actionsFor(license: IssuedLicense): LifecycleAction[] { - switch (license.status) { - case 'ACTIVE': - case 'EXPIRED': - return ['suspend', 'revoke']; - case 'SUSPENDED': - return ['reinstate', 'revoke']; - default: - return []; - } -} - function LifecycleModal({ license, onClose, @@ -160,7 +136,7 @@ function LifecycleModal({ */ export function LicenseRegisterPage() { const [search, setSearch] = useState(''); - const { data, isLoading } = useGetLicensesQuery( + const { data, isLoading, refetch } = useGetLicensesQuery( search.trim() ? { search: search.trim() } : undefined, ); const [target, setTarget] = useState(null); @@ -186,83 +162,16 @@ export function LicenseRegisterPage() { - {isLoading ? ( -
- -
- ) : items.length === 0 ? ( -
- - {search ? 'No licences match that search.' : 'No licences issued yet.'} - -
- ) : ( - - - - Certificate № - Type - Holder - Issued - Expires - Status - - - - - {items.map((license) => ( - - - - {license.certificateNumber} - - - - - {localized(license.licenseType?.name)} - - - - {license.companyName ?? '—'} - - - - {license.issueDate?.slice(0, 10)} - - - - - {license.expiryDate?.slice(0, 10)} - - - - - {license.status} - - - - {actionsFor(license).length > 0 && ( - - - - )} - - - ))} - -
- )} + license.id} + loading={isLoading} + onRefresh={refetch} + emptyTitle={ + search ? 'No licences match that search.' : 'No licences issued yet.' + } + />
setTarget(null)} /> diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx new file mode 100644 index 000000000..b0d5ba3c5 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx @@ -0,0 +1,106 @@ +import { Badge, Button, Group, Text, Tooltip } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import { + STATUS_COLORS, + STATUS_LABELS, + type LicenseApplication, +} from '@ema-platform/api'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import { computeSla } from '../../sla'; + +export function licenseQueueColumns( + t: TFunction, + locale: string, + handlers: { + claiming: boolean; + onClaim: (app: LicenseApplication) => void; + onOpen: (app: LicenseApplication) => void; + }, +): AdvancedTableColumn[] { + return [ + { + key: 'applicationNumber', + header: t('queue.number', 'App #'), + sortable: true, + render: (app) => ( + + {app.applicationNumber} + + ), + }, + { + key: 'companyName', + header: t('queue.company', 'Company'), + sortable: true, + render: (app) => {app.companyName ?? '—'}, + }, + { + key: 'tin', + header: t('queue.tin', 'TIN'), + render: (app) => ( + + {app.tinNumber ?? '—'} + + ), + }, + { + key: 'type', + header: t('queue.typeCol', 'Type'), + render: (app) => {app.licenseType?.name?.en ?? '—'}, + }, + { + key: 'status', + header: t('queue.statusCol', 'Status'), + sortable: true, + render: (app) => ( + + {STATUS_LABELS[app.status]} + + ), + }, + { + key: 'submittedAt', + header: t('queue.submitted', 'Submitted'), + sortable: true, + render: (app) => ( + + {app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'} + + ), + }, + { + key: 'sla', + header: t('queue.sla', 'Age / SLA'), + render: (app) => { + const sla = computeSla(app); + return ( + // Colour is never the only signal — the label says the same thing. + + + {sla.label} + + + ); + }, + }, + { + // Claim/Review are text buttons, so they stay a regular column rather + // than being redesigned into AdvancedTable icon actions. + key: 'actions', + header: '', + render: (app) => ( + + {app.assignedOfficerId === null && app.status === 'SUBMITTED' ? ( + + ) : ( + + )} + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx similarity index 69% rename from apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx rename to apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx index 818af2802..9f5508ca3 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx @@ -5,11 +5,9 @@ import { Badge, Button, Card, - Checkbox, Container, Group, MultiSelect, - Pagination, Paper, SegmentedControl, Select, @@ -17,7 +15,6 @@ import { Stack, Kbd, Modal, - Table, Tabs, Text, TextInput, @@ -30,14 +27,11 @@ import { IconDownload, IconRefresh, IconSearch, - IconSortAscending, - IconSortDescending, IconX, } from '@tabler/icons-react'; import { notifications } from '@mantine/notifications'; import { useTranslation } from 'react-i18next'; import { - STATUS_COLORS, STATUS_LABELS, extractErrorMessage, useClaimApplicationMutation, @@ -47,12 +41,11 @@ import { useGetQueueCountsQuery, useGetQueueQuery, useLazyExportApplicationsQuery, - type LicenseApplication, type LicenseStatus, type QueueFilter, } from '@ema-platform/api'; -import { EmptyState, ErrorState } from '@ema-platform/ui'; -import { computeSla } from '../sla'; +import { AdvancedTable, EmptyState, ErrorState } from '@ema-platform/ui'; +import { licenseQueueColumns } from './columns'; import { DEFAULT_VIEW, SAVED_VIEWS, @@ -61,11 +54,11 @@ import { searchParamsFromFilter, writeLastView, type SavedViewId, -} from '../queue-views'; -import { exportApplicationsCsv } from '../export'; -import { setDensity } from '../../../store/preferences.slice'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard'; +} from '../../queue-views'; +import { exportApplicationsCsv } from '../../export'; +import { setDensity } from '../../../../store/preferences.slice'; +import { useAppDispatch, useAppSelector } from '../../../../store/hooks'; +import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../../useQueueKeyboard'; const PAGE_SIZE = 25; const SEARCH_DEBOUNCE_MS = 300; @@ -275,10 +268,6 @@ export function LicenseQueuePage() { onHelp: () => setHelpOpen(true), }); - const allSelected = items.length > 0 && selected.length === items.length; - const sortIcon = - urlFilter.sortDir === 'DESC' ? : ; - const hasFacets = Boolean( urlFilter.status?.length || urlFilter.licenseTypeId || @@ -446,95 +435,48 @@ export function LicenseQueuePage() { /> ) : ( <> - - - - - - 0 && !allSelected} - onChange={() => - setSelected(allSelected ? [] : items.map((a) => a.id)) - } - /> - - - - {t('queue.tin', 'TIN')} - {t('queue.typeCol', 'Type')} - - - {t('queue.sla', 'Age / SLA')} - - - - - {items.map((app, index) => ( - - setSelected((prev) => - checked ? [...prev, app.id] : prev.filter((id) => id !== app.id), - ) - } - onClaim={() => handleClaim(app.id)} - onOpen={() => navigate(`/licence-review/${app.id}`)} - /> - ))} - -
-
- - - - {t('queue.showing', { - from: (page - 1) * PAGE_SIZE + 1, - to: Math.min(page * PAGE_SIZE, total), - total, - defaultValue: 'Showing {{from}}–{{to}} of {{total}}', - })} - - { + handleClaim(app.id), + onOpen: (app) => navigate(`/licence-review/${app.id}`), + })} + data={items} + rowKey={(app) => app.id} + selection={{ selected, onChange: setSelected }} + sort={{ + sortBy: urlFilter.sortBy, + sortDir: urlFilter.sortDir === 'DESC' ? 'desc' : 'asc', + onSort: (field) => + toggleSort(field as NonNullable), + }} + pagination={{ + page, + totalPages: pageCount, + onPageChange: (next) => { setPage(next); updateUrl({}, view, next); - }} - total={pageCount} - size="sm" - /> - + }, + }} + minWidth={1100} + verticalSpacing={density === 'compact' ? 4 : 'sm'} + // Keyboard cursor. Marked with a left border rather than a + // background so it stays distinguishable from row selection and + // from hover. + rowStyle={(app) => + app.id === cursorRow?.id + ? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' } + : undefined + } + /> + + {t('queue.showing', { + from: (page - 1) * PAGE_SIZE + 1, + to: Math.min(page * PAGE_SIZE, total), + total, + defaultValue: 'Showing {{from}}–{{to}} of {{total}}', + })} + )} @@ -601,122 +543,4 @@ export function LicenseQueuePage() { ); } -function SortableTh({ - label, - field, - current, - icon, - onSort, -}: { - label: string; - field: NonNullable; - current?: QueueFilter['sortBy']; - icon: React.ReactNode; - onSort: (field: NonNullable) => void; -}) { - return ( - - onSort(field)} - > - {label} - {current === field && icon} - - - ); -} - -function QueueRow({ - app, - selected, - focused, - claiming, - locale, - onSelect, - onClaim, - onOpen, -}: { - app: LicenseApplication; - selected: boolean; - focused: boolean; - claiming: boolean; - locale: string; - onSelect: (checked: boolean) => void; - onClaim: () => void; - onOpen: () => void; -}) { - const { t } = useTranslation(); - const sla = computeSla(app); - - return ( - - - onSelect(e.currentTarget.checked)} - /> - - - - {app.applicationNumber} - - - - {app.companyName ?? '—'} - - - - {app.tinNumber ?? '—'} - - - - {app.licenseType?.name?.en ?? '—'} - - - - {STATUS_LABELS[app.status]} - - - - - {app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'} - - - - {/* Colour is never the only signal — the label says the same thing. */} - - - {sla.label} - - - - - - {app.assignedOfficerId === null && app.status === 'SUBMITTED' ? ( - - ) : ( - - )} - - - - ); -} - export default LicenseQueuePage; diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/columns.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/columns.tsx new file mode 100644 index 000000000..869a14b0b --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/columns.tsx @@ -0,0 +1,80 @@ +import { Badge, Checkbox, Group, Text, TextInput } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { ApplicationStaff } from '@ema-platform/api'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; + +export function reviewStaffColumns( + t: TFunction, + handlers: { + flags: Record; + onToggleFlag: (member: ApplicationStaff) => void; + onRemarkChange: (member: ApplicationStaff, remark: string) => void; + }, +): AdvancedTableColumn[] { + const { flags } = handlers; + return [ + { + key: 'roleKey', + header: t('review.role', 'Role'), + render: (member) => {member.roleKey}, + }, + { + key: 'fullName', + header: t('review.name', 'Name'), + render: (member) => {member.fullName}, + }, + { + key: 'evidence', + header: t('review.evidence', 'Evidence'), + render: (member) => ( + + {(member.documents ?? []).map((doc) => ( + + {doc.documentKey} + + ))} + + ), + }, + { + // A person's papers are as returnable as a document or a form section: + // an ERB certificate for the wrong person is a defect the applicant has + // to fix, and until now the officer had to describe it under some + // unrelated document. + key: 'correction', + header: t('review.correction', 'Correction'), + width: 260, + render: (member) => ( + <> + handlers.onToggleFlag(member)} + /> + {flags[member.id] && ( + { + const remark = e.currentTarget.value; + handlers.onRemarkChange(member, remark); + }} + /> + )} + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx similarity index 90% rename from apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx rename to apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 426b76e7f..7148272bd 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -57,19 +57,20 @@ import { useScheduleInspectionMutation, type RemarkTargetType, } from '@ema-platform/api'; -import { ErrorState } from '@ema-platform/ui'; +import { AdvancedTable, ErrorState } from '@ema-platform/ui'; import { usePermissions } from '@ema-platform/auth'; -import { useAppSelector } from '../../../store/hooks'; -import { DecisionBar } from '../components/DecisionBar'; +import { useAppSelector } from '../../../../store/hooks'; +import { DecisionBar } from '../../components/DecisionBar'; import { DecisionConfirmModal, type DecisionSubmission, -} from '../components/DecisionConfirmModal'; -import { ActivityRail } from '../components/ActivityRail'; -import { DocumentsTab } from '../components/DocumentsTab'; -import { computeSla } from '../sla'; -import { evaluateEligibility, presentationFor } from '../config/license-types'; -import { resolveActions, type ActionId, type ResolvedAction } from '../config/actions'; +} from '../../components/DecisionConfirmModal'; +import { ActivityRail } from '../../components/ActivityRail'; +import { DocumentsTab } from '../../components/DocumentsTab'; +import { computeSla } from '../../sla'; +import { reviewStaffColumns } from './columns'; +import { evaluateEligibility, presentationFor } from '../../config/license-types'; +import { resolveActions, type ActionId, type ResolvedAction } from '../../config/actions'; type FlagMap = Record; @@ -764,79 +765,20 @@ export function LicenseReviewPage() { - - - - {t('review.role', 'Role')} - {t('review.name', 'Name')} - {t('review.evidence', 'Evidence')} - - {t('review.correction', 'Correction')} - - - - - {data.staff.map((member) => ( - - - {member.roleKey} - - - {member.fullName} - - - - {(member.documents ?? []).map((doc) => ( - - {doc.documentKey} - - ))} - - - {/* A person's papers are as returnable as a document - or a form section: an ERB certificate for the wrong - person is a defect the applicant has to fix, and - until now the officer had to describe it under some - unrelated document. */} - - toggleFlag('STAFF', member.id)} - /> - {flags[member.id] && ( - { - const remark = e.currentTarget.value; - setFlags((p) => ({ - ...p, - [member.id]: { ...p[member.id], remark }, - })); - }} - /> - )} - - - ))} - -
+ toggleFlag('STAFF', member.id), + onRemarkChange: (member, remark) => + setFlags((p) => ({ + ...p, + [member.id]: { ...p[member.id], remark }, + })), + })} + data={data.staff} + rowKey={(member) => member.id} + onRefresh={refetch} + />
diff --git a/apps/backoffice/src/app/features/location/components/LocationTypeModal/actions.tsx b/apps/backoffice/src/app/features/location/components/LocationTypeModal/actions.tsx new file mode 100644 index 000000000..f986258de --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal/actions.tsx @@ -0,0 +1,29 @@ +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableAction } from '@ema-platform/ui'; +import type { LocationType } from '../../types/location'; + +export function locationTypeColumnActions( + t: TFunction, + handlers: { + onEdit: (type: LocationType) => void; + onDelete: (type: LocationType) => void; + }, +): AdvancedTableAction[] { + return [ + { + key: 'edit', + label: t('location.edit'), + color: 'blue', + icon: , + onClick: handlers.onEdit, + }, + { + key: 'delete', + label: t('location.delete'), + color: 'red', + icon: , + onClick: handlers.onDelete, + }, + ]; +} diff --git a/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx b/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx new file mode 100644 index 000000000..646c5a31f --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx @@ -0,0 +1,35 @@ +import { Badge } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import type { LocationType } from '../../types/location'; + +export function locationTypeColumns( + t: TFunction, + locale: 'en' | 'am', +): AdvancedTableColumn[] { + return [ + { + key: 'level', + header: t('location.level'), + render: (type) => ( + + {type.level} + + ), + }, + { + key: 'code', + header: t('location.code'), + render: (type) => ( + + {type.code} + + ), + }, + { + key: 'name', + header: t('location.name'), + render: (type) => type.names[locale], + }, + ]; +} diff --git a/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx b/apps/backoffice/src/app/features/location/components/LocationTypeModal/index.tsx similarity index 67% rename from apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx rename to apps/backoffice/src/app/features/location/components/LocationTypeModal/index.tsx index 8db7faa67..054922bc9 100644 --- a/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal/index.tsx @@ -6,23 +6,20 @@ import { Group, Stack, NumberInput, - Table, - ActionIcon, - Badge, - Text, - Tooltip, Divider, } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { IconEdit, IconTrash, IconPlus } from '@tabler/icons-react'; +import { IconPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { useGetLocationTypesQuery, useCreateLocationTypeMutation, useUpdateLocationTypeMutation, useDeleteLocationTypeMutation, -} from '../api/location-api'; -import { notify } from '@ema-platform/ui'; +} from '../../api/location-api'; +import { AdvancedTable, notify } from '@ema-platform/ui'; +import { locationTypeColumns } from './columns'; +import { locationTypeColumnActions } from './actions'; interface LocationTypeFormValues { code: string; @@ -34,7 +31,7 @@ interface LocationTypeFormValues { export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; - const { data: locationTypes, isLoading } = useGetLocationTypesQuery(); + const { data: locationTypes, isLoading, refetch } = useGetLocationTypesQuery(); const [createType] = useCreateLocationTypeMutation(); const [updateType] = useUpdateLocationTypeMutation(); const [deleteType] = useDeleteLocationTypeMutation(); @@ -171,61 +168,18 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos - {isLoading && Loading...} - {!isLoading && sortedTypes.length === 0 && ( - - {t('location.noTypes')} - - )} - {sortedTypes.length > 0 && ( - - - - {t('location.level')} - {t('location.code')} - {t('location.name')} - - - - - {sortedTypes.map((type) => ( - - - - {type.level} - - - - - {type.code} - - - {type.names[locale]} - - - handleEdit(type)} - > - - - handleDelete(type.id)} - > - - - - - - ))} - -
- )} + type.id} + actions={locationTypeColumnActions(t, { + onEdit: handleEdit, + onDelete: (type) => handleDelete(type.id), + })} + loading={isLoading} + onRefresh={refetch} + emptyTitle={t('location.noTypes')} + />
); } diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx new file mode 100644 index 000000000..2a0c4f817 --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx @@ -0,0 +1,39 @@ +import { Badge, Text } from '@mantine/core'; +import type { AdvancedTableColumn } from '@ema-platform/ui'; +import { + STATUS_COLORS, + STATUS_LABELS, + type LicenseApplication, + type LicenseStatus, +} from '@ema-platform/api'; + +export const logisticsHeadDashboardColumns: AdvancedTableColumn[] = + [ + { + key: 'applicationNumber', + header: 'Number', + render: (app) => ( + + {app.applicationNumber} + + ), + }, + { + key: 'companyName', + header: 'Company', + render: (app) => {app.companyName ?? '—'}, + }, + { + key: 'status', + header: 'Status', + render: (app) => ( + + {STATUS_LABELS[app.status as LicenseStatus]} + + ), + }, + ]; diff --git a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage.tsx b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/index.tsx similarity index 71% rename from apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage.tsx rename to apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/index.tsx index 4c5747525..00ea5c138 100644 --- a/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage.tsx +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/index.tsx @@ -9,11 +9,11 @@ import { Loader, SimpleGrid, Stack, - Table, Text, Title, } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; +import { AdvancedTable } from '@ema-platform/ui'; import { STATUS_COLORS, STATUS_LABELS, @@ -21,6 +21,7 @@ import { useGetQueueQuery, type LicenseStatus, } from '@ema-platform/api'; +import { logisticsHeadDashboardColumns } from './columns'; /** * Logistics department overview. @@ -110,50 +111,17 @@ export function LogisticsHeadDashboardPage() { View all - {recent.length === 0 ? ( -
- - No licence applications yet. - -
- ) : ( - - - - Number - Company - Status - - - - {recent.map((app) => ( - navigate(`/licence-review/${app.id}`)} - > - - - {app.applicationNumber} - - - - {app.companyName ?? '—'} - - - - {STATUS_LABELS[app.status as LicenseStatus]} - - - - ))} - -
- )} + app.id} + onRefresh={() => { + queue.refetch(); + mine.refetch(); + }} + emptyTitle="No licence applications yet." + onRowClick={(app) => navigate(`/licence-review/${app.id}`)} + /> diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx deleted file mode 100644 index ee874f416..000000000 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { useState } from 'react'; -import { - Badge, - Button, - Card, - Center, - Container, - Group, - Loader, - Modal, - Stack, - Table, - Tabs, - Text, - Textarea, - Title, -} from '@mantine/core'; -import { - IconAnchor, - IconCheck, - IconStethoscope, - IconX, -} from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; -import { - extractErrorMessage, - useGetPendingMedicalQuery, - useGetPendingSeaServiceQuery, - useVerifyMedicalCertificateMutation, - useVerifySeaServiceRecordMutation, -} from '@ema-platform/api'; -import type { - MedicalCertificate, - SeaServiceRecord, - SeafarerProfileSummary, -} from '@ema-platform/api'; - -function ownerName(profile?: SeafarerProfileSummary): string { - if (!profile) return '—'; - return ( - [profile.firstName, profile.middleName, profile.lastName] - .filter(Boolean) - .join(' ') || '—' - ); -} - -/** Reject dialog — the remark is what the seafarer sees and must act on. */ -function RejectModal({ - title, - opened, - onClose, - onConfirm, - loading, -}: { - title: string; - opened: boolean; - onClose: () => void; - onConfirm: (remark: string) => void; - loading: boolean; -}) { - const [remark, setRemark] = useState(''); - return ( - - -