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..39357fedf --- /dev/null +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/actions.tsx @@ -0,0 +1,29 @@ +import { ActionIcon, Group } from '@mantine/core'; +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Certification } from '../../types/certification'; + +export function certificationActionsColumn( + t: TFunction, + handlers: { + onEdit: (cert: Certification) => void; + onDelete: (cert: Certification) => void; + }, +): AdvancedColumn { + return { + header: '', + label: t('certification.columns.actions', 'Actions'), + size: 90, + cell: ({ row }) => ( + + handlers.onEdit(row.original)}> + + + handlers.onDelete(row.original)}> + + + + ), + }; +} 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..4a56ea6d0 --- /dev/null +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/columns.tsx @@ -0,0 +1,28 @@ +import { Badge, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Certification } from '../../types/certification'; + +export function certificationColumns( + t: TFunction, + locale: 'en' | 'am', +): AdvancedColumn[] { + return [ + { + header: t('certification.columns.name'), + cell: ({ row }) => {row.original.name[locale]}, + }, + { + header: t('certification.columns.description'), + cell: ({ row }) => {row.original.description[locale]}, + }, + { + header: t('certification.columns.status'), + cell: ({ row }) => ( + + {row.original.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 80% rename from apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx rename to apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx index 88fdbe449..1c5893ab3 100644 --- a/apps/backoffice/src/app/features/certification/pages/CertificationPage.tsx +++ b/apps/backoffice/src/app/features/certification/pages/CertificationPage/index.tsx @@ -4,8 +4,6 @@ import { Title, Group, Button, - Badge, - ActionIcon, Modal, Text, TextInput, @@ -15,15 +13,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, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui'; +import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; +import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui'; 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'; +import { certificationColumns } from './columns'; +import { certificationActionsColumn } from './actions'; function CertificationForm({ editing, @@ -122,38 +122,12 @@ export function CertificationPage() { if (isError) return } color="red" title={t('certification.loadError')} />; - const columns: AdvancedColumn[] = [ - { - header: t('certification.columns.name'), - cell: ({ row }) => {row.original.name[locale]}, - }, - { - header: t('certification.columns.description'), - cell: ({ row }) => {row.original.description[locale]}, - }, - { - header: t('certification.columns.status'), - cell: ({ row }) => ( - - {row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')} - - ), - }, - { - header: '', - label: t('certification.columns.actions', 'Actions'), - size: 90, - cell: ({ row }) => ( - - { setEditing(row.original); setShowForm(true); }}> - - - { setDeleteTarget(row.original); openDelete(); }}> - - - - ), - }, + const columns = [ + ...certificationColumns(t, locale), + certificationActionsColumn(t, { + onEdit: (cert) => { setEditing(cert); setShowForm(true); }, + onDelete: (cert) => { setDeleteTarget(cert); openDelete(); }, + }), ]; const page = paginate(certifications); 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..2ea102e55 --- /dev/null +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/actions.tsx @@ -0,0 +1,34 @@ +import { ActionIcon, Group } from "@mantine/core"; +import { IconEdit, IconTrash } from "@tabler/icons-react"; +import type { AdvancedColumn } from "@ema-platform/ui"; +import type { Profession } from "../../types/configuration"; + +export function professionActionsColumn(handlers: { + onEdit: (prof: Profession) => void; + onDelete: (prof: Profession) => void; +}): AdvancedColumn { + return { + header: "actions", + size: 90, + cell: ({ row }) => ( + + handlers.onEdit(row.original)} + > + + + handlers.onDelete(row.original)} + > + + + + ), + }; +} 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..6079f3d7f --- /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 { AdvancedColumn } from "@ema-platform/ui"; +import type { Profession } from "../../types/configuration"; + +export function professionColumns( + t: TFunction, + locale: "en" | "am", + getDeptName: (deptId: string) => string, +): AdvancedColumn[] { + return [ + { + header: t("configuration.name"), + cell: ({ row }) => row.original.name[locale], + enabled: true, + }, + { + header: t("configuration.description"), + cell: ({ row }) => ( + + {row.original.description[locale]} + + ), + }, + { + header: t("configuration.department"), + cell: ({ row }) => getDeptName(row.original.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 88% rename from apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx rename to apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx index c270c3cf3..e6bfd6059 100644 --- a/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx +++ b/apps/backoffice/src/app/features/configuration/pages/ConfigurationPage/index.tsx @@ -7,7 +7,6 @@ import { Button, TextInput, Textarea, - ActionIcon, Modal, Text, Select, @@ -18,8 +17,6 @@ import { import { useForm } from "@mantine/form"; import { useDisclosure } from "@mantine/hooks"; import { - IconEdit, - IconTrash, IconPlus, IconBriefcase, IconMap, @@ -33,18 +30,19 @@ import { AdvancedTable, useServerTable, ModalFooter, - type AdvancedColumn, } from "@ema-platform/ui"; -import { LocationPage } from "../../location/pages/LocationPage"; -import { CertificationPage } from "../../certification/pages/CertificationPage"; +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"; +import { professionColumns } from "./columns"; +import { professionActionsColumn } from "./actions"; interface ProfFormValues { nameEn: string; @@ -285,48 +283,12 @@ function ProfessionTab() { [departments, locale], ); - const professionColumns: AdvancedColumn[] = [ - { - header: t("configuration.name"), - cell: ({ row }) => row.original.name[locale], - enabled: true, - }, - { - header: t("configuration.description"), - cell: ({ row }) => ( - - {row.original.description[locale]} - - ), - }, - { - header: t("configuration.department"), - cell: ({ row }) => getDeptName(row.original.departmentId), - }, - { - header: "actions", - size: 90, - cell: ({ row }) => ( - - handleEditProf(row.original)} - > - - - handleDeleteProf(row.original)} - > - - - - ), - }, + const columns = [ + ...professionColumns(t, locale, getDeptName), + professionActionsColumn({ + onEdit: handleEditProf, + onDelete: handleDeleteProf, + }), ]; if (isLoading) { @@ -374,7 +336,7 @@ function ProfessionTab() { )} [] = [ + { + header: 'Number', + cell: ({ row }) => ( + + {row.original.applicationNumber} + + ), + }, + { + header: 'Company', + cell: ({ row }) => {row.original.companyName ?? '—'}, + }, + { + header: 'Status', + cell: ({ row }) => ( + + {STATUS_LABELS[row.original.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..443b66eae 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, useServerTable } from '@ema-platform/ui'; +import { dashboardQueueColumns } from './columns'; /** * Backoffice home. @@ -31,6 +25,7 @@ export function DashboardPage() { const navigate = useNavigate(); const queue = useGetQueueQuery(); const mine = useGetAssignedToMeQuery(); + const table = useServerTable(); if (queue.isLoading || mine.isLoading) { return ( @@ -43,6 +38,7 @@ export function DashboardPage() { const unclaimed = queue.data?.items ?? []; const inProgress = mine.data?.items ?? []; const all = [...unclaimed, ...inProgress]; + const paged = table.paginate(unclaimed.slice(0, 8)); const stats = [ { label: 'Awaiting claim', value: unclaimed.length, color: 'blue' }, @@ -95,49 +91,18 @@ 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]} - - - - ))} - -
- )} + navigate('/licence-review')} + refresh={queue.refetch} + emptyText="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..9a1798567 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx @@ -0,0 +1,92 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconUserCheck } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } 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 }, +): AdvancedColumn[] { + return [ + { + header: t('exam.candidates.admission'), + cell: ({ row }) => ( + + {row.original.admissionNumber} + + ), + }, + { + header: t('exam.candidates.name'), + cell: ({ row }) => {candidateName(row.original)}, + }, + { + header: t('exam.candidates.attempt'), + cell: ({ row }) => ( + + {row.original.kind === 'RETAKE' + ? t('exam.candidates.retake', { n: row.original.attemptNumber }) + : t('exam.candidates.firstSitting')} + + ), + }, + { + header: t('exam.candidates.attendance'), + cell: ({ row }) => ( + + {t(`exam.attendance.${row.original.attendanceStatus}`)} + + ), + }, + { + header: t('exam.candidates.remark'), + cell: ({ row }) => ( + + {row.original.attendanceRemark ?? '—'} + + ), + }, + { + header: '', + label: t('exam.candidates.record'), + align: 'right', + cell: ({ row }) => ( + + ), + }, + ]; +} 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..e02dad17c 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, useServerTable } 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,22 @@ 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 table = useServerTable(); - 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; @@ -90,6 +82,8 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) { // register; the endpoint refuses them and there is nothing to show. if (isError) return null; + const paged = table.paginate(registrations ?? []); + return ( @@ -100,75 +94,16 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) { {t('exam.candidates.none')} </Alert> ) : ( - <Table striped highlightOnHover> - <Table.Thead> - <Table.Tr> - <Table.Th>{t('exam.candidates.admission')}</Table.Th> - <Table.Th>{t('exam.candidates.name')}</Table.Th> - <Table.Th>{t('exam.candidates.attempt')}</Table.Th> - <Table.Th>{t('exam.candidates.attendance')}</Table.Th> - <Table.Th>{t('exam.candidates.remark')}</Table.Th> - <Table.Th /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {(registrations ?? []).map((registration) => ( - <Table.Tr key={registration.id}> - <Table.Td> - <Text fz="sm" ff="monospace" fw={600}> - {registration.admissionNumber} - </Text> - </Table.Td> - <Table.Td> - <Text fz="sm">{candidateName(registration)}</Text> - </Table.Td> - <Table.Td> - <Badge - size="sm" - variant="light" - color={registration.kind === 'RETAKE' ? 'orange' : 'blue'} - > - {registration.kind === 'RETAKE' - ? t('exam.candidates.retake', { n: registration.attemptNumber }) - : t('exam.candidates.firstSitting')} - </Badge> - </Table.Td> - <Table.Td> - <Badge - size="sm" - variant="light" - color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'} - > - {t(`exam.attendance.${registration.attendanceStatus}`)} - </Badge> - </Table.Td> - <Table.Td> - <Text fz="xs" c="dimmed" maw={220} lineClamp={2}> - {registration.attendanceRemark ?? '—'} - </Text> - </Table.Td> - <Table.Td> - <Button - size="compact-xs" - variant="light" - leftSection={<IconUserCheck size={12} />} - onClick={() => { - setTarget(registration); - setStatus( - registration.attendanceStatus === 'REGISTERED' - ? 'PRESENT' - : registration.attendanceStatus, - ); - setRemark(registration.attendanceRemark ?? ''); - }} - > - {t('exam.candidates.record')} - </Button> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> + <AdvancedTable + tableName={t('exam.candidates.section')} + columns={examCandidateColumns(t, { onRecord: startRecording })} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + refresh={refetch} + /> )} <Modal diff --git a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx new file mode 100644 index 000000000..974e96595 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/columns.tsx @@ -0,0 +1,84 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { ExamIncident, ExamIncidentStatus } from '../../types/exam'; + +const STATUS_COLOR: Record<ExamIncidentStatus, string> = { + OPEN: 'red', + UNDER_REVIEW: 'yellow', + RESOLVED: 'teal', + DISMISSED: 'gray', +}; + +export function examIncidentColumns( + t: TFunction, + showDate: (date: string | null | undefined) => string, + handlers: { onResolve: (incident: ExamIncident) => void }, +): AdvancedColumn<ExamIncident>[] { + return [ + { + header: t('exam.incidents.type'), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color="orange"> + {t(`exam.incidentType.${row.original.type}`)} + </Badge> + ), + }, + { + header: t('exam.incidents.candidate'), + cell: ({ row }) => ( + <Text fz="xs"> + {row.original.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')} + </Text> + ), + }, + { + header: t('exam.incidents.description'), + cell: ({ row }) => ( + <> + <Text fz="xs" maw={260} lineClamp={2}> + {row.original.description} + </Text> + {row.original.resolution && ( + <Text fz="xs" c="dimmed" maw={260} lineClamp={2}> + ⤷ {row.original.resolution} + </Text> + )} + </> + ), + }, + { + header: t('exam.incidents.occurred'), + cell: ({ row }) => <Text fz="xs">{showDate(row.original.occurredAt)}</Text>, + }, + { + header: t('exam.incidents.status'), + cell: ({ row }) => ( + <Badge + size="sm" + variant="light" + color={STATUS_COLOR[row.original.status] ?? 'gray'} + > + {t(`exam.incidentStatus.${row.original.status}`)} + </Badge> + ), + }, + { + header: '', + label: t('exam.incidents.resolve'), + align: 'right', + cell: ({ row }) => + row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? ( + <Button + size="compact-xs" + variant="light" + leftSection={<IconAlertTriangle size={12} />} + onClick={() => handlers.onResolve(row.original)} + > + {t('exam.incidents.resolve')} + </Button> + ) : null, + }, + ]; +} 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 ecbd5dac9..f41802210 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel/index.tsx @@ -2,20 +2,18 @@ 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, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage } from '@ema-platform/api'; import { @@ -23,12 +21,9 @@ import { 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', @@ -38,13 +33,6 @@ const TYPES: ExamIncidentType[] = [ 'OTHER', ]; -const STATUS_COLOR: Record<ExamIncidentStatus, string> = { - 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 @@ -53,7 +41,8 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = { export function ExamIncidentsPanel({ examId }: { examId: string }) { const { t } = useTranslation(); const showDate = useDateDisplayer(); - const { data: incidents, isError } = useGetExamIncidentsQuery(examId); + const { data: incidents, isError, refetch } = useGetExamIncidentsQuery(examId); + const table = useServerTable(); const { data: registrations } = useGetExamRegistrationsQuery(examId); const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation(); const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation(); @@ -98,6 +87,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')); @@ -121,6 +116,8 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) { // endpoint refuses them, so there is nothing to render. if (isError) return null; + const paged = table.paginate(incidents ?? []); + return ( <Paper withBorder radius="lg" p="lg"> <Group justify="space-between" mb="md"> @@ -141,74 +138,16 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) { {t('exam.incidents.none')} </Alert> ) : ( - <Table striped> - <Table.Thead> - <Table.Tr> - <Table.Th>{t('exam.incidents.type')}</Table.Th> - <Table.Th>{t('exam.incidents.candidate')}</Table.Th> - <Table.Th>{t('exam.incidents.description')}</Table.Th> - <Table.Th>{t('exam.incidents.occurred')}</Table.Th> - <Table.Th>{t('exam.incidents.status')}</Table.Th> - <Table.Th /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {(incidents ?? []).map((incident) => ( - <Table.Tr key={incident.id}> - <Table.Td> - <Badge size="sm" variant="light" color="orange"> - {t(`exam.incidentType.${incident.type}`)} - </Badge> - </Table.Td> - <Table.Td> - <Text fz="xs"> - {incident.registration?.admissionNumber ?? - t('exam.incidents.wholeRoom')} - </Text> - </Table.Td> - <Table.Td> - <Text fz="xs" maw={260} lineClamp={2}> - {incident.description} - </Text> - {incident.resolution && ( - <Text fz="xs" c="dimmed" maw={260} lineClamp={2}> - ⤷ {incident.resolution} - </Text> - )} - </Table.Td> - <Table.Td> - <Text fz="xs">{showDate(incident.occurredAt)}</Text> - </Table.Td> - <Table.Td> - <Badge - size="sm" - variant="light" - color={STATUS_COLOR[incident.status] ?? 'gray'} - > - {t(`exam.incidentStatus.${incident.status}`)} - </Badge> - </Table.Td> - <Table.Td> - {(incident.status === 'OPEN' || - incident.status === 'UNDER_REVIEW') && ( - <Button - size="compact-xs" - variant="light" - leftSection={<IconAlertTriangle size={12} />} - onClick={() => { - setResolveTarget(incident); - setOutcome('RESOLVED'); - setResolution(''); - }} - > - {t('exam.incidents.resolve')} - </Button> - )} - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> + <AdvancedTable<ExamIncident> + tableName={t('exam.incidents.section')} + columns={examIncidentColumns(t, showDate, { onResolve: startResolve })} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + refresh={refetch} + /> )} <Modal diff --git a/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx b/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx index 136d7ba21..76a548248 100644 --- a/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx +++ b/apps/backoffice/src/app/features/exam/components/QuestionAssigner.tsx @@ -13,14 +13,16 @@ import { } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { IconSearch } from "@tabler/icons-react"; -import type { QuestionBrief, actionTypes } from "../types/exam"; +import type { QuestionBrief } from "../types/exam"; + +type actionTypes = "add" | "remove"; interface QuestionAssignerProps { available: QuestionBrief[]; assigned: QuestionBrief[]; onChange: (assigned: QuestionBrief[]) => void; mode?: "manual" | "random"; - actions: Dispatch<SetStateAction<actionTypes | undefined>>; + actions?: Dispatch<SetStateAction<actionTypes | undefined>>; } function QuestionList({ @@ -143,13 +145,13 @@ export function QuestionAssigner({ const assignSelected = () => { const toAssign = available.filter((q) => selectedLeft.has(q.id)); onChange([...assigned, ...toAssign]); - actions("add"); + actions?.("add"); setSelectedLeft(new Set()); }; const removeSelected = () => { onChange(assigned.filter((q) => !selectedRight.has(q.id))); - actions("remove"); + actions?.("remove"); setSelectedRight(new Set()); }; diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index 022c41807..aff652456 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -39,7 +39,7 @@ import { IconCheck, IconX, } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; import { useGetExamQuery, @@ -456,7 +456,6 @@ export function ExamDetailPage() { assigned={draftQuestions} onChange={setDraftQuestions} mode="manual" - actions={setWhatAction} /> <ModalFooter> <Button variant="default" onClick={closeAssign} size="sm"> diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx new file mode 100644 index 000000000..724c872df --- /dev/null +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/actions.tsx @@ -0,0 +1,47 @@ +import { ActionIcon, Group } from "@mantine/core"; +import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react"; +import type { TFunction } from "i18next"; +import type { AdvancedColumn } from "@ema-platform/ui"; +import type { Exam } from "../../types/exam"; + +export function examActionsColumn( + t: TFunction, + handlers: { + onEdit: (exam: Exam) => void; + onDelete: (exam: Exam) => void; + onDetails: (exam: Exam) => void; + }, +): AdvancedColumn<Exam> { + return { + header: t("exam.columns.actions"), + align: "right", + cell: ({ row }) => ( + <Group gap="xs"> + <ActionIcon + variant="subtle" + color="blue" + size="sm" + onClick={() => handlers.onEdit(row.original)} + > + <IconEdit size={14} /> + </ActionIcon> + <ActionIcon + variant="subtle" + color="red" + size="sm" + onClick={() => handlers.onDelete(row.original)} + > + <IconTrash size={14} /> + </ActionIcon> + <ActionIcon + variant="subtle" + color="red" + size="sm" + onClick={() => handlers.onDetails(row.original)} + > + <IconDetails size={14} /> + </ActionIcon> + </Group> + ), + }; +} 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..a5ec1970f --- /dev/null +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/columns.tsx @@ -0,0 +1,81 @@ +import { Badge, Text } from "@mantine/core"; +import type { TFunction } from "i18next"; +import type { AdvancedColumn } from "@ema-platform/ui"; +import type { Exam } from "../../types/exam"; + +const STATUS_COLOR: Record<string, string> = { + PENDING: "gray", + ACTIVE: "blue", + COMPLETED: "teal", + CANCELLED: "red", + POSTPONED: "orange", + PUBLISHED: "green", +}; + +export function examColumns( + t: TFunction, + locale: "en" | "am", + getCertName: (id: string) => string, + onTitleClick: (exam: Exam) => void, +): AdvancedColumn<Exam>[] { + return [ + { + header: t("exam.columns.title"), + cell: ({ row }) => ( + <Text + fz="sm" + fw={500} + c="blue" + style={{ cursor: "pointer" }} + onClick={() => onTitleClick(row.original)} + > + {row.original.title[locale]} + </Text> + ), + }, + { + header: t("exam.columns.certification"), + cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>, + }, + { + header: t("exam.columns.date"), + cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>, + }, + { + header: t("exam.columns.type"), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}> + {t(`exam.type.${row.original.type}`)} + </Badge> + ), + }, + { + header: t("exam.columns.form"), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}> + {t(`exam.formType.${row.original.form}`)} + </Badge> + ), + }, + { + header: t("exam.columns.venue"), + cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>, + }, + { + header: t("exam.columns.questions"), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color="gray"> + {row.original.questions?.length ?? 0} + </Badge> + ), + }, + { + header: t("exam.columns.status"), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}> + {t(`exam.status.${row.original.status}`)} + </Badge> + ), + }, + ]; +} 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 82% rename from apps/backoffice/src/app/features/exam/pages/ExamPage.tsx rename to apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index a3a5436e4..2b738ed37 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -5,8 +5,6 @@ import { Title, Group, Button, - Badge, - ActionIcon, Modal, Text, TextInput, @@ -17,36 +15,25 @@ import { NumberInput, Tabs, SimpleGrid, - Divider, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { - IconEdit, - IconTrash, IconPlus, IconInfoCircle, IconClipboardList, - IconDetails, } from "@tabler/icons-react"; -import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker, type AdvancedColumn } from "@ema-platform/ui"; -import { useGetCertificationsQuery } from "../../certification/api/certification-api"; +import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } 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<string, string> = { - 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 { examActionsColumn } from "./actions"; function ExamForm({ editing, @@ -436,105 +423,19 @@ export function ExamPage() { /> ); - const columns: AdvancedColumn<Exam>[] = [ - { - header: t("exam.columns.title"), - cell: ({ row }) => ( - <Text - fz="sm" - fw={500} - c="blue" - style={{ cursor: "pointer" }} - onClick={() => navigate(`/exams/${row.original.id}`)} - > - {row.original.title[locale]} - </Text> - ), - }, - { - header: t("exam.columns.certification"), - cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>, - }, - { - header: t("exam.columns.date"), - cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>, - }, - { - header: t("exam.columns.type"), - cell: ({ row }) => ( - <Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}> - {t(`exam.type.${row.original.type}`)} - </Badge> - ), - }, - { - header: t("exam.columns.form"), - cell: ({ row }) => ( - <Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}> - {t(`exam.formType.${row.original.form}`)} - </Badge> - ), - }, - { - header: t("exam.columns.venue"), - cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>, - }, - { - header: t("exam.columns.questions"), - cell: ({ row }) => ( - <Badge size="sm" variant="light" color="gray"> - {row.original.questions?.length ?? 0} - </Badge> - ), - }, - { - header: t("exam.columns.status"), - cell: ({ row }) => ( - <Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}> - {t(`exam.status.${row.original.status}`)} - </Badge> - ), - }, - { - header: t("exam.columns.actions"), - align: "right", - cell: ({ row }) => ( - <Group gap="xs"> - <ActionIcon - variant="subtle" - color="blue" - size="sm" - onClick={() => { - setEditing(row.original); - setShowForm(true); - }} - > - <IconEdit size={14} /> - </ActionIcon> - <ActionIcon - variant="subtle" - color="red" - size="sm" - onClick={() => { - setDeleteTarget(row.original); - openDelete(); - }} - > - <IconTrash size={14} /> - </ActionIcon> - <ActionIcon - variant="subtle" - color="red" - size="sm" - onClick={() => { - navigate(`/exams/${row.original.id}`); - }} - > - <IconDetails size={14} /> - </ActionIcon> - </Group> - ), - }, + const columns = [ + ...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)), + examActionsColumn(t, { + onEdit: (exam) => { + setEditing(exam); + setShowForm(true); + }, + onDelete: (exam) => { + setDeleteTarget(exam); + openDelete(); + }, + onDetails: (exam) => navigate(`/exams/${exam.id}`), + }), ]; const page = paginate(exams); 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 8bf6f83fc..000000000 --- a/apps/backoffice/src/app/features/item/components/ItemTable.tsx +++ /dev/null @@ -1,63 +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, useErrorHandler } from '@ema-platform/ui'; -import { useDateDisplayer } from '@ema-platform/shared'; - -const STATUS_COLORS: Record<Item['status'], string> = { - DRAFT: 'gray', - ACTIVE: 'green', - ARCHIVED: 'orange', -}; - -export function ItemTable() { - const { data, isLoading } = useGetItemsQuery({}); - const [deleteItem] = useDeleteItemMutation(); - const { handleError } = useErrorHandler(); - const showDate = useDateDisplayer(); - - const handleDelete = async (id: string) => { - try { - await deleteItem(id).unwrap(); - notify.success('Item deleted'); - } catch (e) { - handleError(e); - } - }; - - if (isLoading) return <Text>Loading...</Text>; - if (!data?.data.length) return <Text c="dimmed">No items found.</Text>; - - return ( - <Table striped highlightOnHover> - <Table.Thead> - <Table.Tr> - <Table.Th>Name</Table.Th> - <Table.Th>Status</Table.Th> - <Table.Th>Created</Table.Th> - <Table.Th /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {data.data.map((item) => ( - <Table.Tr key={item.id}> - <Table.Td>{item.name}</Table.Td> - <Table.Td> - <Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge> - </Table.Td> - <Table.Td>{showDate(item.createdAt)}</Table.Td> - <Table.Td> - <ActionIcon - color="red" - variant="subtle" - onClick={() => handleDelete(item.id)} - > - <IconTrash size={16} /> - </ActionIcon> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - ); -} 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..9bf839a46 --- /dev/null +++ b/apps/backoffice/src/app/features/item/components/ItemTable/actions.tsx @@ -0,0 +1,28 @@ +import { ActionIcon, Group, Tooltip } from '@mantine/core'; +import { IconTrash } from '@tabler/icons-react'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Item } from '../../api/item-api'; + +export function itemActionsColumn(handlers: { + onDelete: (item: Item) => void; +}): AdvancedColumn<Item> { + return { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => ( + <Group gap="xs" wrap="nowrap" justify="flex-end"> + <Tooltip label="Delete"> + <ActionIcon + color="red" + variant="subtle" + aria-label="Delete" + onClick={() => handlers.onDelete(row.original)} + > + <IconTrash size={16} /> + </ActionIcon> + </Tooltip> + </Group> + ), + }; +} 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..7a0f9e447 --- /dev/null +++ b/apps/backoffice/src/app/features/item/components/ItemTable/columns.tsx @@ -0,0 +1,25 @@ +import { Badge } from '@mantine/core'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Item } from '../../api/item-api'; + +const STATUS_COLORS: Record<Item['status'], string> = { + DRAFT: 'gray', + ACTIVE: 'green', + ARCHIVED: 'orange', +}; + +export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] { + return [ + { header: 'Name', accessorKey: 'name' }, + { + header: 'Status', + cell: ({ row }) => ( + <Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge> + ), + }, + { + header: 'Created', + cell: ({ row }) => showDate(row.original.createdAt), + }, + ]; +} 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..c750a54b8 --- /dev/null +++ b/apps/backoffice/src/app/features/item/components/ItemTable/index.tsx @@ -0,0 +1,39 @@ +import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../../api/item-api'; +import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; +import { itemColumns } from './columns'; +import { itemActionsColumn } from './actions'; + +export function ItemTable() { + const { data, isLoading, refetch } = useGetItemsQuery({}); + const [deleteItem] = useDeleteItemMutation(); + const { handleError } = useErrorHandler(); + const showDate = useDateDisplayer(); + const table = useServerTable(); + + const handleDelete = async (item: Item) => { + try { + await deleteItem(item.id).unwrap(); + notify.success('Item deleted'); + } catch (e) { + handleError(e); + } + }; + + const paged = table.paginate(data?.data ?? []); + + return ( + <AdvancedTable<Item> + tableName="Items" + columns={[...itemColumns(showDate), itemActionsColumn({ onDelete: handleDelete })]} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + isLoading={isLoading} + refresh={refetch} + emptyText="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..edc3332bd --- /dev/null +++ b/apps/backoffice/src/app/features/license-register/pages/LicenseRegisterPage/columns.tsx @@ -0,0 +1,100 @@ +import { Badge, Button, Text, Tooltip } from '@mantine/core'; +import { IconShieldCog } from '@tabler/icons-react'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Bilingual, IssuedLicense } from '@ema-platform/api'; + +const LICENSE_STATUS_COLORS: Record<string, string> = { + 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( + localized: (value: Bilingual | undefined) => string, + showDate: (date: string | null | undefined) => string, + handlers: { onStatus: (license: IssuedLicense) => void }, +): AdvancedColumn<IssuedLicense>[] { + return [ + { + header: 'Certificate №', + cell: ({ row }) => ( + <Text size="sm" ff="monospace" fw={600}> + {row.original.certificateNumber} + </Text> + ), + }, + { + header: 'Type', + cell: ({ row }) => ( + <Text size="sm">{localized(row.original.licenseType?.name)}</Text> + ), + }, + { + header: 'Holder', + cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>, + }, + { + header: 'Issued', + cell: ({ row }) => ( + <Text size="sm" c="dimmed"> + {showDate(row.original.issueDate)} + </Text> + ), + }, + { + header: 'Expires', + cell: ({ row }) => ( + <Text size="sm" c="dimmed"> + {showDate(row.original.expiryDate)} + </Text> + ), + }, + { + header: 'Status', + cell: ({ row }) => ( + <Badge + size="sm" + variant="light" + color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'} + > + {row.original.status} + </Badge> + ), + }, + { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => + actionsFor(row.original).length > 0 ? ( + <Tooltip label="Suspend / revoke / reinstate"> + <Button + size="compact-xs" + variant="subtle" + leftSection={<IconShieldCog size={14} />} + onClick={() => handlers.onStatus(row.original)} + > + Status + </Button> + </Tooltip> + ) : 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 5dc41726b..754d0af80 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,24 +1,19 @@ 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, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage, @@ -29,16 +24,11 @@ import { useSuspendLicenseMutation, } from '@ema-platform/api'; import type { IssuedLicense } from '@ema-platform/api'; - -const LICENSE_STATUS_COLORS: Record<string, string> = { - 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, @@ -61,19 +51,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, @@ -161,7 +138,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<IssuedLicense | null>(null); @@ -169,6 +146,8 @@ export function LicenseRegisterPage() { const items = data?.items ?? []; const showDate = useDateDisplayer(); const localized = useLocalized(); + const table = useServerTable(); + const paged = table.paginate(items); return ( <Container size="xl" py="md"> @@ -189,83 +168,20 @@ export function LicenseRegisterPage() { </Group> <Card withBorder padding={0}> - {isLoading ? ( - <Center h={200}> - <Loader /> - </Center> - ) : items.length === 0 ? ( - <Center h={160}> - <Text size="sm" c="dimmed"> - {search ? 'No licences match that search.' : 'No licences issued yet.'} - </Text> - </Center> - ) : ( - <Table highlightOnHover> - <Table.Thead> - <Table.Tr> - <Table.Th>Certificate №</Table.Th> - <Table.Th>Type</Table.Th> - <Table.Th>Holder</Table.Th> - <Table.Th>Issued</Table.Th> - <Table.Th>Expires</Table.Th> - <Table.Th>Status</Table.Th> - <Table.Th /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {items.map((license) => ( - <Table.Tr key={license.id}> - <Table.Td> - <Text size="sm" ff="monospace" fw={600}> - {license.certificateNumber} - </Text> - </Table.Td> - <Table.Td> - <Text size="sm"> - {localized(license.licenseType?.name)} - </Text> - </Table.Td> - <Table.Td> - <Text size="sm">{license.companyName ?? '—'}</Text> - </Table.Td> - <Table.Td> - <Text size="sm" c="dimmed"> - {showDate(license.issueDate)} - </Text> - </Table.Td> - <Table.Td> - <Text size="sm" c="dimmed"> - {showDate(license.expiryDate)} - </Text> - </Table.Td> - <Table.Td> - <Badge - size="sm" - variant="light" - color={LICENSE_STATUS_COLORS[license.status] ?? 'gray'} - > - {license.status} - </Badge> - </Table.Td> - <Table.Td> - {actionsFor(license).length > 0 && ( - <Tooltip label="Suspend / revoke / reinstate"> - <Button - size="compact-xs" - variant="subtle" - leftSection={<IconShieldCog size={14} />} - onClick={() => setTarget(license)} - > - Status - </Button> - </Tooltip> - )} - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - )} + <AdvancedTable<IssuedLicense> + tableName="Licence register" + columns={licenseRegisterColumns(localized, showDate, { onStatus: setTarget })} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + isLoading={isLoading} + refresh={refetch} + emptyText={ + search ? 'No licences match that search.' : 'No licences issued yet.' + } + /> </Card> <LifecycleModal license={target} onClose={() => setTarget(null)} /> 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 new file mode 100644 index 000000000..305f923af --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/actions.tsx @@ -0,0 +1,39 @@ +import { Button } from "@mantine/core"; +import type { TFunction } from "i18next"; +import type { LicenseApplication } from "@ema-platform/api"; +import type { AdvancedColumn } from "@ema-platform/ui"; + +export function licenseQueueActionsColumn( + t: TFunction, + handlers: { + claiming: boolean; + onClaim: (id: string) => void; + onOpen: (id: string) => void; + }, +): AdvancedColumn<LicenseApplication> { + return { + header: "", + label: t("queue.actionsColumn", "Actions"), + align: "right", + size: 140, + cell: ({ row }) => + row.original.assignedOfficerId === null && + row.original.status === "SUBMITTED" ? ( + <Button + size="xs" + loading={handlers.claiming} + onClick={() => handlers.onClaim(row.original.id)} + > + {t("queue.claim", "Claim")} + </Button> + ) : ( + <Button + size="xs" + variant="light" + onClick={() => handlers.onOpen(row.original.id)} + > + {t("queue.review", "Review")} + </Button> + ), + }; +} 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..ed0bf3e91 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/columns.tsx @@ -0,0 +1,142 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { Badge, Checkbox, Text, Tooltip } from "@mantine/core"; +import type { TFunction } from "i18next"; +import { + APPLICANT_NAME_TYPE_KEYS, + STATUS_COLORS, + STATUS_LABELS, + applicantOrCompanyName, + localized, + type LicenseApplication, + type QueueFilter, +} from "@ema-platform/api"; +import type { AdvancedColumn } from "@ema-platform/ui"; +import { dateDisplayer } from "@ema-platform/shared"; +import { computeSla } from "../../sla"; + +export function licenseQueueColumns( + t: TFunction, + locale: string, + opts: { + typeCode: string | undefined; + items: LicenseApplication[]; + selected: string[]; + setSelected: Dispatch<SetStateAction<string[]>>; + allSelected: boolean; + sortableHeader: ( + label: string, + field: NonNullable<QueueFilter["sortBy"]>, + ) => ReactNode; + }, +): AdvancedColumn<LicenseApplication>[] { + const { typeCode, items, selected, setSelected, allSelected, sortableHeader } = + opts; + return [ + { + header: ( + <Checkbox + aria-label={t("queue.selectAll", "Select all")} + checked={allSelected} + indeterminate={selected.length > 0 && !allSelected} + onChange={() => + setSelected(allSelected ? [] : items.map((a) => a.id)) + } + /> + ), + size: 40, + cell: ({ row }) => ( + <Checkbox + aria-label={t("queue.selectRow", { + number: row.original.applicationNumber, + defaultValue: "Select {{number}}", + })} + checked={selected.includes(row.original.id)} + onChange={(e) => { + const checked = e.currentTarget.checked; + setSelected((prev) => + checked + ? [...prev, row.original.id] + : prev.filter((id) => id !== row.original.id), + ); + }} + /> + ), + }, + { + header: sortableHeader(t("queue.number", "App #"), "applicationNumber"), + label: t("queue.number", "App #"), + cell: ({ row }) => ( + <Text size="sm" fw={500}> + {row.original.applicationNumber} + </Text> + ), + }, + { + header: sortableHeader( + typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode) + ? t("queue.applicant", "Applicant") + : t("queue.company", "Company"), + "companyName", + ), + label: t("queue.company", "Company"), + cell: ({ row }) => ( + <Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text> + ), + }, + { + header: t("queue.tin", "TIN"), + cell: ({ row }) => ( + <Text size="sm" c="dimmed"> + {row.original.tinNumber ?? "—"} + </Text> + ), + }, + { + header: t("queue.typeCol", "Type"), + cell: ({ row }) => ( + <Text size="sm"> + {localized(row.original.licenseType?.name, locale) || "—"} + </Text> + ), + }, + { + header: sortableHeader(t("queue.statusCol", "Status"), "status"), + label: t("queue.statusCol", "Status"), + cell: ({ row }) => ( + <Badge color={STATUS_COLORS[row.original.status]} variant="light"> + {t( + `queue.statusValues.${row.original.status}`, + STATUS_LABELS[row.original.status], + )} + </Badge> + ), + }, + { + header: sortableHeader( + t("queue.submitted", "Submitted"), + "submittedAt", + ), + label: t("queue.submitted", "Submitted"), + cell: ({ row }) => ( + <Text size="sm" c="dimmed"> + {dateDisplayer(row.original.submittedAt, locale)} + </Text> + ), + }, + { + header: t("queue.sla", "Age / SLA"), + cell: ({ row }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature + const sla = computeSla(row.original, undefined, locale, (key, options) => t(key, options as any) as string); + return ( + // Colour is never the only signal — the label says the same thing. + <Tooltip label={sla.tooltip} withArrow> + <Badge color={sla.color} variant="light" size="sm"> + {sla.label} + </Badge> + </Tooltip> + ); + }, + }, + ]; +} 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 80% 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 be7861ce5..517ea6120 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage/index.tsx @@ -4,7 +4,6 @@ import { Badge, Button, Card, - Checkbox, Container, Group, MultiSelect, @@ -19,7 +18,6 @@ import { Text, TextInput, Title, - Tooltip, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { @@ -33,10 +31,7 @@ import { import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { - APPLICANT_NAME_TYPE_KEYS, - STATUS_COLORS, STATUS_LABELS, - applicantOrCompanyName, extractErrorMessage, localized, useClaimApplicationMutation, @@ -57,8 +52,6 @@ import { AmharicDatePicker, type AdvancedColumn, } from "@ema-platform/ui"; -import { dateDisplayer } from "@ema-platform/shared"; -import { computeSla } from "../sla"; import { DEFAULT_VIEW, SAVED_VIEWS, @@ -67,11 +60,13 @@ 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"; +import { licenseQueueColumns } from "./columns"; +import { licenseQueueActionsColumn } from "./actions"; const PAGE_SIZE = 10; const SEARCH_DEBOUNCE_MS = 300; @@ -348,137 +343,19 @@ export function LicenseQueuePage() { const columns: AdvancedColumn<LicenseApplication>[] = useMemo( () => [ - { - header: ( - <Checkbox - aria-label={t("queue.selectAll", "Select all")} - checked={allSelected} - indeterminate={selected.length > 0 && !allSelected} - onChange={() => - setSelected(allSelected ? [] : items.map((a) => a.id)) - } - /> - ), - size: 40, - cell: ({ row }) => ( - <Checkbox - aria-label={t("queue.selectRow", { - number: row.original.applicationNumber, - defaultValue: "Select {{number}}", - })} - checked={selected.includes(row.original.id)} - onChange={(e) => { - const checked = e.currentTarget.checked; - setSelected((prev) => - checked - ? [...prev, row.original.id] - : prev.filter((id) => id !== row.original.id), - ); - }} - /> - ), - }, - { - header: sortableHeader(t("queue.number", "App #"), "applicationNumber"), - label: t("queue.number", "App #"), - cell: ({ row }) => ( - <Text size="sm" fw={500}> - {row.original.applicationNumber} - </Text> - ), - }, - { - header: sortableHeader( - typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode) - ? t("queue.applicant", "Applicant") - : t("queue.company", "Company"), - "companyName", - ), - label: t("queue.company", "Company"), - cell: ({ row }) => ( - <Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text> - ), - }, - { - header: t("queue.tin", "TIN"), - cell: ({ row }) => ( - <Text size="sm" c="dimmed"> - {row.original.tinNumber ?? "—"} - </Text> - ), - }, - { - header: t("queue.typeCol", "Type"), - cell: ({ row }) => ( - <Text size="sm"> - {localized(row.original.licenseType?.name, i18n.language) || "—"} - </Text> - ), - }, - { - header: sortableHeader(t("queue.statusCol", "Status"), "status"), - label: t("queue.statusCol", "Status"), - cell: ({ row }) => ( - <Badge color={STATUS_COLORS[row.original.status]} variant="light"> - {t( - `queue.statusValues.${row.original.status}`, - STATUS_LABELS[row.original.status], - )} - </Badge> - ), - }, - { - header: sortableHeader( - t("queue.submitted", "Submitted"), - "submittedAt", - ), - label: t("queue.submitted", "Submitted"), - cell: ({ row }) => ( - <Text size="sm" c="dimmed"> - {dateDisplayer(row.original.submittedAt, i18n.language)} - </Text> - ), - }, - { - header: t("queue.sla", "Age / SLA"), - cell: ({ row }) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature - const sla = computeSla(row.original, undefined, i18n.language, (key, options) => t(key, options as any) as string); - return ( - // Colour is never the only signal — the label says the same thing. - <Tooltip label={sla.tooltip} withArrow> - <Badge color={sla.color} variant="light" size="sm"> - {sla.label} - </Badge> - </Tooltip> - ); - }, - }, - { - header: "", - label: t("queue.actionsColumn", "Actions"), - align: "right", - size: 140, - cell: ({ row }) => - row.original.assignedOfficerId === null && - row.original.status === "SUBMITTED" ? ( - <Button - size="xs" - loading={claiming} - onClick={() => handleClaim(row.original.id)} - > - {t("queue.claim", "Claim")} - </Button> - ) : ( - <Button - size="xs" - variant="light" - onClick={() => navigate(`/licence-review/${row.original.id}`)} - > - {t("queue.review", "Review")} - </Button> - ), - }, + ...licenseQueueColumns(t, i18n.language, { + typeCode, + items, + selected, + setSelected, + allSelected, + sortableHeader, + }), + licenseQueueActionsColumn(t, { + claiming, + onClaim: handleClaim, + onOpen: (id) => navigate(`/licence-review/${id}`), + }), ], [ t, 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..994bc8a3a --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/columns.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from 'react'; +import { Checkbox, Text, TextInput } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { ApplicationStaff } from '@ema-platform/api'; +import type { AdvancedColumn } from '@ema-platform/ui'; + +export function reviewStaffColumns( + t: TFunction, + handlers: { + flags: Record<string, { remark: string }>; + /** Bilingual role label resolved from the licence-type config. */ + roleName: (member: ApplicationStaff) => string; + /** Evidence badges — needs an attachments query, so the page supplies it. */ + renderEvidence: (member: ApplicationStaff) => ReactNode; + onToggleFlag: (member: ApplicationStaff) => void; + onRemarkChange: (member: ApplicationStaff, remark: string) => void; + }, +): AdvancedColumn<ApplicationStaff>[] { + const { flags } = handlers; + return [ + { + header: t('review.role', 'Role'), + cell: ({ row }) => <Text size="xs">{handlers.roleName(row.original)}</Text>, + }, + { + header: t('review.name', 'Name'), + cell: ({ row }) => <Text size="sm">{row.original.fullName}</Text>, + }, + { + header: t('review.evidence', 'Evidence'), + cell: ({ row }) => handlers.renderEvidence(row.original), + }, + { + // 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. + header: t('review.correction', 'Correction'), + size: 260, + cell: ({ row }) => { + const member = row.original; + return ( + <> + <Checkbox + size="xs" + label={t('review.needsCorrection', 'Needs correction')} + checked={Boolean(flags[member.id])} + onChange={() => handlers.onToggleFlag(member)} + /> + {flags[member.id] && ( + <TextInput + mt={6} + size="xs" + withAsterisk + placeholder={t( + 'review.correctionPlaceholder', + 'What must the applicant correct?', + )} + error={ + flags[member.id].remark.trim() + ? null + : t('review.correctionRequired', 'Say what must be corrected') + } + value={flags[member.id].remark} + onChange={(e) => { + 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 91% 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 b9e7c41ef..360969bc2 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -59,20 +59,27 @@ import { useScheduleInspectionMutation, type RemarkTargetType, } from '@ema-platform/api'; -import { ErrorState, ModalFooter, AmharicDatePicker } from '@ema-platform/ui'; +import { + AdvancedTable, + AmharicDatePicker, + ErrorState, + ModalFooter, + useServerTable, +} from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; 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<string, { targetType: RemarkTargetType; remark: string }>; @@ -153,6 +160,7 @@ export function LicenseReviewPage() { // silently reassigning to whoever already held the application. const { data: officers = [] } = useGetAssignableOfficersQuery(); + const staffTable = useServerTable(); const [flags, setFlags] = useState<FlagMap>({}); const [capital, setCapital] = useState<number | undefined>(); const [pendingAction, setPendingAction] = useState<ResolvedAction | null>(null); @@ -280,6 +288,7 @@ export function LicenseReviewPage() { const app = data.application; const status = app.status; + const staffPaged = staffTable.paginate(data.staff); const presentation = presentationFor(app.licenseType?.key); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature const sla = computeSla(app, undefined, i18n.language, (key, options) => t(key, options as any) as string); @@ -792,75 +801,29 @@ export function LicenseReviewPage() { </Tabs.Panel> <Tabs.Panel value="staff"> - <Card withBorder padding="md"> - <Table withTableBorder> - <Table.Thead> - <Table.Tr> - <Table.Th>{t('review.role', 'Role')}</Table.Th> - <Table.Th>{t('review.name', 'Name')}</Table.Th> - <Table.Th>{t('review.evidence', 'Evidence')}</Table.Th> - <Table.Th w={260}> - {t('review.correction', 'Correction')} - </Table.Th> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {data.staff.map((member) => ( - <Table.Tr key={member.id}> - <Table.Td> - <Text size="xs">{localized(roleNameByKey.get(member.roleKey)) || member.roleKey}</Text> - </Table.Td> - <Table.Td> - <Text size="sm">{member.fullName}</Text> - </Table.Td> - <Table.Td> - <StaffEvidenceCell staffId={member.id} fallback={member.documents} /> - </Table.Td> - {/* 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. */} - <Table.Td> - <Checkbox - size="xs" - label={t('review.needsCorrection', 'Needs correction')} - checked={Boolean(flags[member.id])} - onChange={() => toggleFlag('STAFF', member.id)} - /> - {flags[member.id] && ( - <TextInput - mt={6} - size="xs" - withAsterisk - placeholder={t( - 'review.correctionPlaceholder', - 'What must the applicant correct?', - )} - error={ - flags[member.id].remark.trim() - ? null - : t( - 'review.correctionRequired', - 'Say what must be corrected', - ) - } - value={flags[member.id].remark} - onChange={(e) => { - const remark = e.currentTarget.value; - setFlags((p) => ({ - ...p, - [member.id]: { ...p[member.id], remark }, - })); - }} - /> - )} - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - </Card> + <AdvancedTable + tableName={t('review.tabs.staff', 'Staff')} + columns={reviewStaffColumns(t, { + flags, + roleName: (member) => + localized(roleNameByKey.get(member.roleKey)) || member.roleKey, + renderEvidence: (member) => ( + <StaffEvidenceCell staffId={member.id} fallback={member.documents} /> + ), + onToggleFlag: (member) => toggleFlag('STAFF', member.id), + onRemarkChange: (member, remark) => + setFlags((p) => ({ + ...p, + [member.id]: { ...p[member.id], remark }, + })), + })} + data={staffPaged.rows} + itemCount={staffPaged.itemCount} + pageIndex={staffPaged.pageIndex} + onPageChange={staffTable.setPageIndex} + pageSize={staffTable.pageSize} + refresh={refetch} + /> </Tabs.Panel> <Tabs.Panel value="inspection"> 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..87eb4daec --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal/actions.tsx @@ -0,0 +1,43 @@ +import { ActionIcon, Group, Tooltip } from '@mantine/core'; +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { LocationType } from '../../types/location'; + +export function locationTypeColumnActions( + t: TFunction, + handlers: { + onEdit: (type: LocationType) => void; + onDelete: (type: LocationType) => void; + }, +): AdvancedColumn<LocationType> { + return { + header: '', + label: t('location.actions', 'Actions'), + align: 'right', + cell: ({ row }) => ( + <Group gap="xs" wrap="nowrap" justify="flex-end"> + <Tooltip label={t('location.edit')}> + <ActionIcon + color="blue" + variant="subtle" + aria-label={t('location.edit')} + onClick={() => handlers.onEdit(row.original)} + > + <IconEdit size={14} /> + </ActionIcon> + </Tooltip> + <Tooltip label={t('location.delete')}> + <ActionIcon + color="red" + variant="subtle" + aria-label={t('location.delete')} + onClick={() => handlers.onDelete(row.original)} + > + <IconTrash size={14} /> + </ActionIcon> + </Tooltip> + </Group> + ), + }; +} 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..b06652e68 --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx @@ -0,0 +1,32 @@ +import { Badge } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { LocationType } from '../../types/location'; + +export function locationTypeColumns( + t: TFunction, + locale: 'en' | 'am', +): AdvancedColumn<LocationType>[] { + return [ + { + header: t('location.level'), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color="gray"> + {row.original.level} + </Badge> + ), + }, + { + header: t('location.code'), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color="blue"> + {row.original.code} + </Badge> + ), + }, + { + header: t('location.name'), + cell: ({ row }) => row.original.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 84f05ab29..2bb16aea6 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, useErrorHandler } from '@ema-platform/ui'; +} from '../../api/location-api'; +import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; +import { locationTypeColumns } from './columns'; +import { locationTypeColumnActions } from './actions'; interface LocationTypeFormValues { code: string; @@ -35,7 +32,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; const { handleError } = useErrorHandler(); - const { data: locationTypes, isLoading } = useGetLocationTypesQuery(); + const { data: locationTypes, isLoading, refetch } = useGetLocationTypesQuery(); + const table = useServerTable(); const [createType] = useCreateLocationTypeMutation(); const [updateType] = useUpdateLocationTypeMutation(); const [deleteType] = useDeleteLocationTypeMutation(); @@ -108,6 +106,7 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos const sortedTypes = locationTypes?.items ? [...locationTypes.items].sort((a, b) => a.level - b.level) : []; + const paged = table.paginate(sortedTypes); return ( <Modal @@ -172,61 +171,24 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos <Divider mb="md" /> - {isLoading && <Text c="dimmed">Loading...</Text>} - {!isLoading && sortedTypes.length === 0 && ( - <Text c="dimmed" ta="center" py="xl"> - {t('location.noTypes')} - </Text> - )} - {sortedTypes.length > 0 && ( - <Table striped highlightOnHover> - <Table.Thead> - <Table.Tr> - <Table.Th>{t('location.level')}</Table.Th> - <Table.Th>{t('location.code')}</Table.Th> - <Table.Th>{t('location.name')}</Table.Th> - <Table.Th /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {sortedTypes.map((type) => ( - <Table.Tr key={type.id}> - <Table.Td> - <Badge size="sm" variant="light" color="gray"> - {type.level} - </Badge> - </Table.Td> - <Table.Td> - <Badge size="sm" variant="light" color="blue"> - {type.code} - </Badge> - </Table.Td> - <Table.Td>{type.names[locale]}</Table.Td> - <Table.Td> - <Group gap="xs"> - <ActionIcon - variant="subtle" - color="blue" - size="sm" - onClick={() => handleEdit(type)} - > - <IconEdit size={14} /> - </ActionIcon> - <ActionIcon - variant="subtle" - color="red" - size="sm" - onClick={() => handleDelete(type.id)} - > - <IconTrash size={14} /> - </ActionIcon> - </Group> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - )} + <AdvancedTable + tableName={t('location.manageTypes')} + columns={[ + ...locationTypeColumns(t, locale), + locationTypeColumnActions(t, { + onEdit: handleEdit, + onDelete: (type) => handleDelete(type.id), + }), + ]} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + isLoading={isLoading} + refresh={refetch} + emptyText={t('location.noTypes')} + /> </Modal> ); } 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..817fbc21b --- /dev/null +++ b/apps/backoffice/src/app/features/logistics-head/pages/LogisticsHeadDashboardPage/columns.tsx @@ -0,0 +1,36 @@ +import { Badge, Text } from '@mantine/core'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import { + STATUS_COLORS, + STATUS_LABELS, + type LicenseApplication, + type LicenseStatus, +} from '@ema-platform/api'; + +export const logisticsHeadDashboardColumns: AdvancedColumn<LicenseApplication>[] = + [ + { + header: 'Number', + cell: ({ row }) => ( + <Text size="sm" fw={500}> + {row.original.applicationNumber} + </Text> + ), + }, + { + header: 'Company', + cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>, + }, + { + header: 'Status', + cell: ({ row }) => ( + <Badge + size="sm" + variant="light" + color={STATUS_COLORS[row.original.status as LicenseStatus]} + > + {STATUS_LABELS[row.original.status as LicenseStatus]} + </Badge> + ), + }, + ]; 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..dc5ed4af4 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, useServerTable } 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. @@ -33,6 +34,7 @@ export function LogisticsHeadDashboardPage() { const navigate = useNavigate(); const queue = useGetQueueQuery(); const mine = useGetAssignedToMeQuery(); + const table = useServerTable(); if (queue.isLoading || mine.isLoading) { return ( @@ -72,6 +74,8 @@ export function LogisticsHeadDashboardPage() { ) .slice(0, 8); + const paged = table.paginate(recent); + return ( <Container size="xl" py="md"> <Title order={3} mb="xs"> @@ -110,50 +114,21 @@ export function LogisticsHeadDashboardPage() { View all <IconChevronRight size={11} style={{ verticalAlign: -1 }} /> </Text> </Group> - {recent.length === 0 ? ( - <Center py="xl"> - <Text size="sm" c="dimmed"> - No licence applications yet. - </Text> - </Center> - ) : ( - <Table highlightOnHover> - <Table.Thead> - <Table.Tr> - <Table.Th>Number</Table.Th> - <Table.Th>Company</Table.Th> - <Table.Th>Status</Table.Th> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {recent.map((app) => ( - <Table.Tr - key={app.id} - style={{ cursor: 'pointer' }} - onClick={() => navigate(`/licence-review/${app.id}`)} - > - <Table.Td> - <Text size="sm" fw={500}> - {app.applicationNumber} - </Text> - </Table.Td> - <Table.Td> - <Text size="sm">{app.companyName ?? '—'}</Text> - </Table.Td> - <Table.Td> - <Badge - size="sm" - variant="light" - color={STATUS_COLORS[app.status as LicenseStatus]} - > - {STATUS_LABELS[app.status as LicenseStatus]} - </Badge> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - )} + <AdvancedTable + tableName="Most recent" + columns={logisticsHeadDashboardColumns} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + refresh={() => { + queue.refetch(); + mine.refetch(); + }} + emptyText="No licence applications yet." + onRowClick={(app) => navigate(`/licence-review/${app.id}`)} + /> </Card> </Grid.Col> diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/actions.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/actions.tsx new file mode 100644 index 000000000..e03b361b3 --- /dev/null +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/actions.tsx @@ -0,0 +1,99 @@ +import { Button, Group } from '@mantine/core'; +import { IconCheck, IconPaperclip, IconX } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; + +export function medicalActionsColumn( + t: TFunction, + handlers: { + ruling: boolean; + onEvidence: (certificate: MedicalCertificate) => void; + onVerify: (certificate: MedicalCertificate) => void; + onReject: (certificate: MedicalCertificate) => void; + }, +): AdvancedColumn<MedicalCertificate> { + return { + header: '', + label: t('recordVerification.columns.actions', 'Actions'), + align: 'right', + cell: ({ row }) => ( + <Group gap="xs" justify="flex-end" wrap="nowrap"> + <Button + size="compact-xs" + color="blue" + variant="light" + leftSection={<IconPaperclip size={14} />} + onClick={() => handlers.onEvidence(row.original)} + > + {t('recordVerification.evidence', 'Evidence')} + </Button> + <Button + size="compact-xs" + color="teal" + leftSection={<IconCheck size={14} />} + loading={handlers.ruling} + onClick={() => handlers.onVerify(row.original)} + > + {t('recordVerification.verify', 'Verify')} + </Button> + <Button + size="compact-xs" + color="red" + variant="light" + leftSection={<IconX size={14} />} + onClick={() => handlers.onReject(row.original)} + > + {t('recordVerification.reject', 'Reject')} + </Button> + </Group> + ), + }; +} + +export function seaServiceActionsColumn( + t: TFunction, + handlers: { + ruling: boolean; + onEvidence: (record: SeaServiceRecord) => void; + onVerify: (record: SeaServiceRecord) => void; + onReject: (record: SeaServiceRecord) => void; + }, +): AdvancedColumn<SeaServiceRecord> { + return { + header: '', + label: t('recordVerification.columns.actions', 'Actions'), + align: 'right', + cell: ({ row }) => ( + <Group gap="xs" justify="flex-end" wrap="nowrap"> + <Button + size="compact-xs" + color="blue" + variant="light" + leftSection={<IconPaperclip size={14} />} + onClick={() => handlers.onEvidence(row.original)} + > + {t('recordVerification.evidence', 'Evidence')} + </Button> + <Button + size="compact-xs" + color="teal" + leftSection={<IconCheck size={14} />} + loading={handlers.ruling} + onClick={() => handlers.onVerify(row.original)} + > + {t('recordVerification.verify', 'Verify')} + </Button> + <Button + size="compact-xs" + color="red" + variant="light" + leftSection={<IconX size={14} />} + onClick={() => handlers.onReject(row.original)} + > + {t('recordVerification.reject', 'Reject')} + </Button> + </Group> + ), + }; +} diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx new file mode 100644 index 000000000..9712b6719 --- /dev/null +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx @@ -0,0 +1,131 @@ +import { Badge, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { + MedicalCertificate, + SeaServiceRecord, + SeafarerProfileSummary, +} from '@ema-platform/api'; + +export function ownerName(profile?: SeafarerProfileSummary): string { + if (!profile) return '—'; + return ( + [profile.firstName, profile.middleName, profile.lastName] + .filter(Boolean) + .join(' ') || '—' + ); +} + +export function medicalColumns( + t: TFunction, + showDate: (date: string) => string, +): AdvancedColumn<MedicalCertificate>[] { + return [ + { + header: t('recordVerification.columns.seafarer', 'Seafarer'), + label: t('recordVerification.columns.seafarer', 'Seafarer'), + accessorKey: 'profile.firstName', + cell: ({ row }) => ( + <div> + <Text size="sm" fw={500}> + {ownerName(row.original.profile)} + </Text> + {row.original.profile?.seafarerNumber && ( + <Text size="xs" c="dimmed" ff="monospace"> + {row.original.profile.seafarerNumber} + </Text> + )} + </div> + ), + }, + { + header: t('recordVerification.columns.issuer', 'Issuer'), + label: t('recordVerification.columns.issuer', 'Issuer'), + accessorKey: 'issuerName', + cell: ({ row }) => ( + <div> + <Text size="sm">{row.original.issuerName}</Text> + {row.original.certificateNumber && ( + <Text size="xs" c="dimmed"> + № {row.original.certificateNumber} + </Text> + )} + </div> + ), + }, + { + header: t('recordVerification.columns.validity', 'Validity'), + label: t('recordVerification.columns.validity', 'Validity'), + cell: ({ row }) => ( + <Text size="sm"> + {showDate(row.original.issueDate)} → {showDate(row.original.expiryDate)} + </Text> + ), + }, + { + header: t('recordVerification.columns.fitness', 'Fitness'), + label: t('recordVerification.columns.fitness', 'Fitness'), + accessorKey: 'fitnessStatus', + cell: ({ row }) => ( + <Badge size="sm" variant="light"> + {t(`recordVerification.fitness.${row.original.fitnessStatus}`, row.original.fitnessStatus)} + </Badge> + ), + }, + ]; +} + +export function seaServiceColumns( + t: TFunction, + showDate: (date: string) => string, +): AdvancedColumn<SeaServiceRecord>[] { + return [ + { + header: t('recordVerification.columns.seafarer', 'Seafarer'), + label: t('recordVerification.columns.seafarer', 'Seafarer'), + accessorKey: 'profile.firstName', + cell: ({ row }) => ( + <div> + <Text size="sm" fw={500}> + {ownerName(row.original.profile)} + </Text> + {row.original.profile?.seafarerNumber && ( + <Text size="xs" c="dimmed" ff="monospace"> + {row.original.profile.seafarerNumber} + </Text> + )} + </div> + ), + }, + { + header: t('recordVerification.columns.vessel', 'Vessel'), + label: t('recordVerification.columns.vessel', 'Vessel'), + accessorKey: 'vesselName', + cell: ({ row }) => ( + <div> + <Text size="sm">{row.original.vesselName}</Text> + {row.original.imoNumber && ( + <Text size="xs" c="dimmed"> + IMO {row.original.imoNumber} + </Text> + )} + </div> + ), + }, + { + header: t('recordVerification.columns.rank', 'Rank'), + label: t('recordVerification.columns.rank', 'Rank'), + accessorKey: 'rank', + cell: ({ row }) => <Text size="sm">{row.original.rank}</Text>, + }, + { + header: t('recordVerification.columns.period', 'Period'), + label: t('recordVerification.columns.period', 'Period'), + cell: ({ row }) => ( + <Text size="sm"> + {showDate(row.original.engagementDate)} → {showDate(row.original.dischargeDate)} + </Text> + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx similarity index 60% rename from apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx rename to apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx index c99ee9d38..018208283 100644 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx @@ -17,12 +17,10 @@ import { } from '@mantine/core'; import { IconAnchor, - IconCheck, IconEye, IconInbox, IconPaperclip, IconStethoscope, - IconX, } from '@tabler/icons-react'; import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; @@ -34,23 +32,12 @@ import { useVerifyMedicalCertificateMutation, useVerifySeaServiceRecordMutation, } from '@ema-platform/api'; -import type { - MedicalCertificate, - SeaServiceRecord, - SeafarerProfileSummary, -} from '@ema-platform/api'; +import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; +import { medicalColumns, seaServiceColumns, ownerName } from './columns'; +import { medicalActionsColumn, seaServiceActionsColumn } from './actions'; const PAGE_SIZE = 10; -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, @@ -274,220 +261,60 @@ export function MedicalVerificationPage() { setSeaServicePage(0); }, []); - const medicalColumns: AdvancedColumn<MedicalCertificate>[] = useMemo( + const medicalTableColumns: AdvancedColumn<MedicalCertificate>[] = useMemo( () => [ - { - header: t('recordVerification.columns.seafarer', 'Seafarer'), - label: t('recordVerification.columns.seafarer', 'Seafarer'), - accessorKey: 'profile.firstName', - cell: ({ row }) => ( - <div> - <Text size="sm" fw={500}> - {ownerName(row.original.profile)} - </Text> - {row.original.profile?.seafarerNumber && ( - <Text size="xs" c="dimmed" ff="monospace"> - {row.original.profile.seafarerNumber} - </Text> - )} - </div> - ), - }, - { - header: t('recordVerification.columns.issuer', 'Issuer'), - label: t('recordVerification.columns.issuer', 'Issuer'), - accessorKey: 'issuerName', - cell: ({ row }) => ( - <div> - <Text size="sm">{row.original.issuerName}</Text> - {row.original.certificateNumber && ( - <Text size="xs" c="dimmed"> - № {row.original.certificateNumber} - </Text> - )} - </div> - ), - }, - { - header: t('recordVerification.columns.validity', 'Validity'), - label: t('recordVerification.columns.validity', 'Validity'), - cell: ({ row }) => ( - <Text size="sm"> - {showDate(row.original.issueDate)} → {showDate(row.original.expiryDate)} - </Text> - ), - }, - { - header: t('recordVerification.columns.fitness', 'Fitness'), - label: t('recordVerification.columns.fitness', 'Fitness'), - accessorKey: 'fitnessStatus', - cell: ({ row }) => ( - <Badge size="sm" variant="light"> - {t(`recordVerification.fitness.${row.original.fitnessStatus}`, row.original.fitnessStatus)} - </Badge> - ), - }, - { - header: '', - label: t('recordVerification.columns.actions', 'Actions'), - align: 'right', - cell: ({ row }) => ( - <Group gap="xs" justify="flex-end" wrap="nowrap"> - <Button - size="compact-xs" - color="blue" - variant="light" - leftSection={<IconPaperclip size={14} />} - onClick={() => - setAttachmentModal({ - ownerType: 'MEDICAL_CERTIFICATE', - ownerId: row.original.id, - title: t('recordVerification.evidenceTitleMedical', { - name: ownerName(row.original.profile), - defaultValue: 'Evidence for {{name}} Medical Certificate', - }), - }) - } - > - {t('recordVerification.evidence', 'Evidence')} - </Button> - <Button - size="compact-xs" - color="teal" - leftSection={<IconCheck size={14} />} - loading={rulingMedical} - onClick={() => - rule( - () => - verifyMedical({ - id: row.original.id, - outcome: 'VERIFIED', - }).unwrap(), - t('recordVerification.certificateVerified', 'Certificate verified'), - ) - } - > - {t('recordVerification.verify', 'Verify')} - </Button> - <Button - size="compact-xs" - color="red" - variant="light" - leftSection={<IconX size={14} />} - onClick={() => setRejectMedical(row.original)} - > - {t('recordVerification.reject', 'Reject')} - </Button> - </Group> - ), - }, + ...medicalColumns(t, showDate), + medicalActionsColumn(t, { + ruling: rulingMedical, + onEvidence: (certificate) => + setAttachmentModal({ + ownerType: 'MEDICAL_CERTIFICATE', + ownerId: certificate.id, + title: t('recordVerification.evidenceTitleMedical', { + name: ownerName(certificate.profile), + defaultValue: 'Evidence for {{name}} Medical Certificate', + }), + }), + onVerify: (certificate) => + rule( + () => + verifyMedical({ + id: certificate.id, + outcome: 'VERIFIED', + }).unwrap(), + t('recordVerification.certificateVerified', 'Certificate verified'), + ), + onReject: (certificate) => setRejectMedical(certificate), + }), ], [rulingMedical, rule, verifyMedical, showDate, t], ); - const seaServiceColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo( + const seaServiceTableColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo( () => [ - { - header: t('recordVerification.columns.seafarer', 'Seafarer'), - label: t('recordVerification.columns.seafarer', 'Seafarer'), - accessorKey: 'profile.firstName', - cell: ({ row }) => ( - <div> - <Text size="sm" fw={500}> - {ownerName(row.original.profile)} - </Text> - {row.original.profile?.seafarerNumber && ( - <Text size="xs" c="dimmed" ff="monospace"> - {row.original.profile.seafarerNumber} - </Text> - )} - </div> - ), - }, - { - header: t('recordVerification.columns.vessel', 'Vessel'), - label: t('recordVerification.columns.vessel', 'Vessel'), - accessorKey: 'vesselName', - cell: ({ row }) => ( - <div> - <Text size="sm">{row.original.vesselName}</Text> - {row.original.imoNumber && ( - <Text size="xs" c="dimmed"> - IMO {row.original.imoNumber} - </Text> - )} - </div> - ), - }, - { - header: t('recordVerification.columns.rank', 'Rank'), - label: t('recordVerification.columns.rank', 'Rank'), - accessorKey: 'rank', - cell: ({ row }) => <Text size="sm">{row.original.rank}</Text>, - }, - { - header: t('recordVerification.columns.period', 'Period'), - label: t('recordVerification.columns.period', 'Period'), - cell: ({ row }) => ( - <Text size="sm"> - {showDate(row.original.engagementDate)} → {showDate(row.original.dischargeDate)} - </Text> - ), - }, - { - header: '', - label: t('recordVerification.columns.actions', 'Actions'), - align: 'right', - cell: ({ row }) => ( - <Group gap="xs" justify="flex-end" wrap="nowrap"> - <Button - size="compact-xs" - color="blue" - variant="light" - leftSection={<IconPaperclip size={14} />} - onClick={() => - setAttachmentModal({ - ownerType: 'SEA_SERVICE_RECORD', - ownerId: row.original.id, - title: t('recordVerification.evidenceTitleSeaService', { - name: ownerName(row.original.profile), - defaultValue: 'Evidence for {{name}} Sea Service Record', - }), - }) - } - > - {t('recordVerification.evidence', 'Evidence')} - </Button> - <Button - size="compact-xs" - color="teal" - leftSection={<IconCheck size={14} />} - loading={rulingSeaService} - onClick={() => - rule( - () => - verifySeaService({ - id: row.original.id, - outcome: 'VERIFIED', - }).unwrap(), - t('recordVerification.seaServiceVerified', 'Sea-service record verified'), - ) - } - > - {t('recordVerification.verify', 'Verify')} - </Button> - <Button - size="compact-xs" - color="red" - variant="light" - leftSection={<IconX size={14} />} - onClick={() => setRejectSeaService(row.original)} - > - {t('recordVerification.reject', 'Reject')} - </Button> - </Group> - ), - }, + ...seaServiceColumns(t, showDate), + seaServiceActionsColumn(t, { + ruling: rulingSeaService, + onEvidence: (record) => + setAttachmentModal({ + ownerType: 'SEA_SERVICE_RECORD', + ownerId: record.id, + title: t('recordVerification.evidenceTitleSeaService', { + name: ownerName(record.profile), + defaultValue: 'Evidence for {{name}} Sea Service Record', + }), + }), + onVerify: (record) => + rule( + () => + verifySeaService({ + id: record.id, + outcome: 'VERIFIED', + }).unwrap(), + t('recordVerification.seaServiceVerified', 'Sea-service record verified'), + ), + onReject: (record) => setRejectSeaService(record), + }), ], [rulingSeaService, rule, verifySeaService, showDate, t], ); @@ -522,7 +349,7 @@ export function MedicalVerificationPage() { <Tabs.Panel value="medical" pt="md"> <AdvancedTable - columns={medicalColumns} + columns={medicalTableColumns} data={pagedMedical} tableName={t('recordVerification.tabs.medical', { count: pendingMedicalList.length, @@ -541,7 +368,7 @@ export function MedicalVerificationPage() { <Tabs.Panel value="sea-service" pt="md"> <AdvancedTable - columns={seaServiceColumns} + columns={seaServiceTableColumns} data={pagedSeaService} tableName={t('recordVerification.tabs.seaService', { count: pendingSeaServiceList.length, diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/actions.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/actions.tsx new file mode 100644 index 000000000..88d98d0b6 --- /dev/null +++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/actions.tsx @@ -0,0 +1,26 @@ +import { Button } from '@mantine/core'; +import { IconEdit } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { LicenseType } from '@ema-platform/api'; +import type { AdvancedColumn } from '@ema-platform/ui'; + +export function paymentConfigActionsColumn( + t: TFunction, + handlers: { onEdit: (type: LicenseType) => void }, +): AdvancedColumn<LicenseType> { + return { + header: '', + size: 90, + align: 'right', + cell: ({ row }) => ( + <Button + size="xs" + variant="light" + leftSection={<IconEdit size={14} />} + onClick={() => handlers.onEdit(row.original)} + > + {t('paymentConfig.edit', 'Edit')} + </Button> + ), + }; +} diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/columns.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/columns.tsx new file mode 100644 index 000000000..a4a418b18 --- /dev/null +++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/columns.tsx @@ -0,0 +1,96 @@ +import { Badge, Text, Tooltip } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { LicenseType } from '@ema-platform/api'; +import type { AdvancedColumn } from '@ema-platform/ui'; + +function feeText(amount: string | number | null, currency: string): string { + if (amount === null || amount === '') return '—'; + const value = Number(amount); + if (!Number.isFinite(value)) return '—'; + return `${value.toLocaleString('en-US')} ${currency}`; +} + +export function paymentConfigColumns( + t: TFunction, + localized: (value: LicenseType['name']) => string, +): AdvancedColumn<LicenseType>[] { + return [ + { + header: t('paymentConfig.columns.type', 'Licence type'), + cell: ({ row }) => ( + <> + <Text size="sm" fw={600}> + {localized(row.original.name)} + </Text> + <Text size="xs" c="dimmed" ff="monospace"> + {row.original.key} + </Text> + </> + ), + }, + { + header: t('paymentConfig.columns.newApplication', 'New application'), + cell: ({ row }) => ( + <Text size="sm" fw={500}> + {feeText(row.original.feeNewApplication, row.original.feeCurrency)} + </Text> + ), + }, + { + header: t('paymentConfig.columns.renewal', 'Renewal'), + cell: ({ row }) => { + const type = row.original; + if (type.feeNewApplication === null) { + // No charge at all, so "same as new" would be noise. + return ( + <Text size="sm" c="dimmed"> + — + </Text> + ); + } + if (type.feeRenewal === null) { + return ( + <Tooltip + label={t( + 'paymentConfig.renewalSameTooltip', + 'No separate renewal fee — renewal is charged at the new-application rate', + )} + > + <Text size="sm" c="dimmed"> + {feeText(type.feeNewApplication, type.feeCurrency)}{' '} + <Text span size="xs" c="dimmed"> + {t('paymentConfig.renewalSameAsNew', '(same as new)')} + </Text> + </Text> + </Tooltip> + ); + } + return ( + <Text size="sm" fw={500}> + {feeText(type.feeRenewal, type.feeCurrency)} + </Text> + ); + }, + }, + { + header: t('paymentConfig.columns.charged', 'Charged?'), + cell: ({ row }) => + row.original.issuesCertificate ? ( + <Badge variant="light" color="teal" size="sm"> + {t('paymentConfig.chargedOnApproval', 'On approval')} + </Badge> + ) : ( + <Tooltip + label={t( + 'paymentConfig.chargedNotChargedTooltip', + 'This licence type ends with an EMA decision and never reaches a payment stage', + )} + > + <Badge variant="light" color="gray" size="sm"> + {t('paymentConfig.chargedNotCharged', 'Not charged')} + </Badge> + </Tooltip> + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx similarity index 78% rename from apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx rename to apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx index 7c10da105..afbb6c624 100644 --- a/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage.tsx +++ b/apps/backoffice/src/app/features/payment-config/pages/PaymentConfigPage/index.tsx @@ -21,7 +21,6 @@ import { import { IconAlertTriangle, IconCreditCard, - IconEdit, IconInfoCircle, IconLock, } from '@tabler/icons-react'; @@ -40,6 +39,8 @@ import { useUpdateLicenseFeesMutation, } from '@ema-platform/api'; import type { LicenseType } from '@ema-platform/api'; +import { paymentConfigColumns } from './columns'; +import { paymentConfigActionsColumn } from './actions'; /** * Licence fee configuration. @@ -52,13 +53,6 @@ import type { LicenseType } from '@ema-platform/api'; * without that permission can read the figures but the save is refused. */ -function feeText(amount: string | number | null, currency: string): string { - if (amount === null || amount === '') return '—'; - const value = Number(amount); - if (!Number.isFinite(value)) return '—'; - return `${value.toLocaleString('en-US')} ${currency}`; -} - export function PaymentConfigPage() { const { t } = useTranslation(); const localized = useLocalized(); @@ -94,98 +88,8 @@ export function PaymentConfigPage() { const page = paginate(types); const columns: AdvancedColumn<LicenseType>[] = [ - { - header: t('paymentConfig.columns.type', 'Licence type'), - cell: ({ row }) => ( - <> - <Text size="sm" fw={600}> - {localized(row.original.name)} - </Text> - <Text size="xs" c="dimmed" ff="monospace"> - {row.original.key} - </Text> - </> - ), - }, - { - header: t('paymentConfig.columns.newApplication', 'New application'), - cell: ({ row }) => ( - <Text size="sm" fw={500}> - {feeText(row.original.feeNewApplication, row.original.feeCurrency)} - </Text> - ), - }, - { - header: t('paymentConfig.columns.renewal', 'Renewal'), - cell: ({ row }) => { - const type = row.original; - if (type.feeNewApplication === null) { - // No charge at all, so "same as new" would be noise. - return ( - <Text size="sm" c="dimmed"> - — - </Text> - ); - } - if (type.feeRenewal === null) { - return ( - <Tooltip - label={t( - 'paymentConfig.renewalSameTooltip', - 'No separate renewal fee — renewal is charged at the new-application rate', - )} - > - <Text size="sm" c="dimmed"> - {feeText(type.feeNewApplication, type.feeCurrency)}{' '} - <Text span size="xs" c="dimmed"> - {t('paymentConfig.renewalSameAsNew', '(same as new)')} - </Text> - </Text> - </Tooltip> - ); - } - return ( - <Text size="sm" fw={500}> - {feeText(type.feeRenewal, type.feeCurrency)} - </Text> - ); - }, - }, - { - header: t('paymentConfig.columns.charged', 'Charged?'), - cell: ({ row }) => - row.original.issuesCertificate ? ( - <Badge variant="light" color="teal" size="sm"> - {t('paymentConfig.chargedOnApproval', 'On approval')} - </Badge> - ) : ( - <Tooltip - label={t( - 'paymentConfig.chargedNotChargedTooltip', - 'This licence type ends with an EMA decision and never reaches a payment stage', - )} - > - <Badge variant="light" color="gray" size="sm"> - {t('paymentConfig.chargedNotCharged', 'Not charged')} - </Badge> - </Tooltip> - ), - }, - { - header: '', - size: 90, - align: 'right', - cell: ({ row }) => ( - <Button - size="xs" - variant="light" - leftSection={<IconEdit size={14} />} - onClick={() => setEditing(row.original)} - > - {t('paymentConfig.edit', 'Edit')} - </Button> - ), - }, + ...paymentConfigColumns(t, localized), + paymentConfigActionsColumn(t, { onEdit: setEditing }), ]; return ( diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx new file mode 100644 index 000000000..c3ad6b700 --- /dev/null +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/actions.tsx @@ -0,0 +1,66 @@ +import { ActionIcon, Button, Group } from '@mantine/core'; +import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Question } from '../../types/question'; + +export function questionActionsColumn( + t: TFunction, + handlers: { + isSubmittingReview: boolean; + onSubmitForApproval: (question: Question) => void; + onReview: (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => void; + onEdit: (question: Question) => void; + onDelete: (question: Question) => void; + }, +): AdvancedColumn<Question> { + return { + header: '', + label: t('question.columns.actions', 'Actions'), + cell: ({ row }) => { + const q = row.original; + return ( + <Group gap="xs"> + {(q.status === 'DRAFT' || q.status === 'REJECTED') && ( + <Button + size="compact-xs" + variant="light" + leftSection={<IconSend size={12} />} + loading={handlers.isSubmittingReview} + onClick={() => handlers.onSubmitForApproval(q)} + > + {t('question.qc.submit')} + </Button> + )} + {q.status === 'PENDING_APPROVAL' && ( + <> + <Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}> + {t('question.qc.approve')} + </Button> + <Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}> + {t('question.qc.reject')} + </Button> + </> + )} + {q.status === 'APPROVED' && ( + <Button + size="compact-xs" + variant="subtle" + color="dark" + leftSection={<IconGavel size={12} />} + onClick={() => handlers.onReview(q, 'RETIRED')} + > + {t('question.qc.retire')} + </Button> + )} + <ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}> + <IconEdit size={14} /> + </ActionIcon> + <ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}> + <IconTrash size={14} /> + </ActionIcon> + </Group> + ); + }, + }; +} diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage/columns.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/columns.tsx new file mode 100644 index 000000000..2968c2c1f --- /dev/null +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/columns.tsx @@ -0,0 +1,52 @@ +import { Badge, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Question, QuestionStatus } from '../../types/question'; + +/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */ +const QC_COLOR: Record<QuestionStatus, string> = { + DRAFT: 'gray', + PENDING_APPROVAL: 'yellow', + APPROVED: 'teal', + REJECTED: 'red', + RETIRED: 'dark', +}; + +export function questionColumns( + t: TFunction, + opts: { + locale: 'en' | 'am'; + getCertName: (id: string) => string; + }, +): AdvancedColumn<Question>[] { + return [ + { + header: t('question.columns.title'), + cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[opts.locale]}</Text>, + }, + { + header: t('question.columns.certification'), + cell: ({ row }) => <Text fz="sm">{opts.getCertName(row.original.certificationId)}</Text>, + }, + { + header: t('question.columns.form'), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}> + {t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)} + </Badge> + ), + }, + { + header: t('question.columns.points'), + cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>, + }, + { + header: t('question.qc.column'), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color={QC_COLOR[row.original.status] ?? 'gray'} title={row.original.reviewRemark ?? undefined}> + {t(`question.qc.${row.original.status}`)} + </Badge> + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx similarity index 77% rename from apps/backoffice/src/app/features/question/pages/QuestionPage.tsx rename to apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx index dfdf184a8..9dfec73ba 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage/index.tsx @@ -5,7 +5,6 @@ import { Group, Button, Badge, - ActionIcon, Modal, Text, TextInput, @@ -17,17 +16,10 @@ import { } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; -import { - IconEdit, - IconTrash, - IconPlus, - IconInfoCircle, - IconSend, - IconGavel, -} from '@tabler/icons-react'; +import { IconPlus, IconInfoCircle } from '@tabler/icons-react'; import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; -import { useGetCertificationsQuery } from '../../certification/api/certification-api'; +import { useGetCertificationsQuery } from '../../../certification/api/certification-api'; import { useGetQuestionsQuery, useCreateQuestionMutation, @@ -35,17 +27,10 @@ import { useDeleteQuestionMutation, useSubmitQuestionMutation, useReviewQuestionMutation, -} from '../api/question-api'; -import type { Question, QuestionForm, QuestionStatus } from '../types/question'; - -/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */ -const QC_COLOR: Record<QuestionStatus, string> = { - DRAFT: 'gray', - PENDING_APPROVAL: 'yellow', - APPROVED: 'teal', - REJECTED: 'red', - RETIRED: 'dark', -}; +} from '../../api/question-api'; +import type { Question, QuestionForm } from '../../types/question'; +import { questionColumns } from './columns'; +import { questionActionsColumn } from './actions'; function QuestionForm({ editing, @@ -219,83 +204,14 @@ export function QuestionPage() { if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />; const columns: AdvancedColumn<Question>[] = [ - { - header: t('question.columns.title'), - cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[locale]}</Text>, - }, - { - header: t('question.columns.certification'), - cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>, - }, - { - header: t('question.columns.form'), - cell: ({ row }) => ( - <Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}> - {t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)} - </Badge> - ), - }, - { - header: t('question.columns.points'), - cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>, - }, - { - header: t('question.qc.column'), - cell: ({ row }) => ( - <Badge size="sm" variant="light" color={QC_COLOR[row.original.status] ?? 'gray'} title={row.original.reviewRemark ?? undefined}> - {t(`question.qc.${row.original.status}`)} - </Badge> - ), - }, - { - header: '', - label: t('question.columns.actions', 'Actions'), - cell: ({ row }) => { - const q = row.original; - return ( - <Group gap="xs"> - {(q.status === 'DRAFT' || q.status === 'REJECTED') && ( - <Button - size="compact-xs" - variant="light" - leftSection={<IconSend size={12} />} - loading={isSubmittingReview} - onClick={() => handleSubmitForApproval(q)} - > - {t('question.qc.submit')} - </Button> - )} - {q.status === 'PENDING_APPROVAL' && ( - <> - <Button size="compact-xs" variant="light" color="teal" onClick={() => openReview(q, 'APPROVED')}> - {t('question.qc.approve')} - </Button> - <Button size="compact-xs" variant="light" color="red" onClick={() => openReview(q, 'REJECTED')}> - {t('question.qc.reject')} - </Button> - </> - )} - {q.status === 'APPROVED' && ( - <Button - size="compact-xs" - variant="subtle" - color="dark" - leftSection={<IconGavel size={12} />} - onClick={() => openReview(q, 'RETIRED')} - > - {t('question.qc.retire')} - </Button> - )} - <ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}> - <IconEdit size={14} /> - </ActionIcon> - <ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}> - <IconTrash size={14} /> - </ActionIcon> - </Group> - ); - }, - }, + ...questionColumns(t, { locale, getCertName }), + questionActionsColumn(t, { + isSubmittingReview, + onSubmitForApproval: handleSubmitForApproval, + onReview: openReview, + onEdit: (q) => { setEditing(q); setShowForm(true); }, + onDelete: (q) => { setDeleteTarget(q); openDelete(); }, + }), ]; return ( diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx new file mode 100644 index 000000000..f224aa828 --- /dev/null +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/columns.tsx @@ -0,0 +1,59 @@ +import { NumberInput, Text, TextInput } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { QuestionBrief } from '../../../exam/types/exam'; + +export function recordResultColumns( + t: TFunction, + locale: 'en' | 'am', + handlers: { + scores: Record<string, number>; + questionRemarks: Record<string, string>; + onScoreChange: (questionId: string, value: number) => void; + onRemarkChange: (questionId: string, value: string) => void; + }, +): AdvancedColumn<QuestionBrief>[] { + return [ + { + header: t('result.recordModal.question'), + cell: ({ row }) => ( + <Text fz="sm" maw={250} lineClamp={2}> + {row.original.title[locale]} + </Text> + ), + }, + { + header: t('result.recordModal.maxPoints'), + cell: ({ row }) => ( + <Text fz="sm" fw={600}> + {row.original.points} + </Text> + ), + }, + { + header: t('result.recordModal.score'), + cell: ({ row }) => ( + <NumberInput + value={handlers.scores[row.original.id] ?? 0} + onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))} + min={0} + max={row.original.points} + size="xs" + style={{ width: 80 }} + /> + ), + }, + { + header: t('result.recordModal.remark'), + cell: ({ row }) => ( + <TextInput + placeholder={t('result.recordModal.remarkOptional')} + value={handlers.questionRemarks[row.original.id] ?? ''} + onChange={(e) => handlers.onRemarkChange(row.original.id, e.currentTarget.value)} + size="xs" + style={{ minWidth: 160 }} + /> + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx similarity index 78% rename from apps/backoffice/src/app/features/result/components/RecordResultModal.tsx rename to apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx index cf64029c4..f24a9c1fa 100644 --- a/apps/backoffice/src/app/features/result/components/RecordResultModal.tsx +++ b/apps/backoffice/src/app/features/result/components/RecordResultModal/index.tsx @@ -5,10 +5,8 @@ import { Stack, Select, Divider, - Table, Text, Badge, - NumberInput, Paper, SimpleGrid, Group, @@ -17,11 +15,12 @@ import { Alert, } from '@mantine/core'; import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; -import { useCreateResultMutation } from '../api/result-api'; -import { useGetExamRegistrationsQuery } from '../../exam/api/exam-api'; -import type { Exam } from '../../exam/types/exam'; +import { recordResultColumns } from './columns'; +import { useCreateResultMutation } from '../../api/result-api'; +import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api'; +import type { Exam } from '../../../exam/types/exam'; function InfoRow({ label, value }: { label: string; value: string }) { return ( @@ -43,7 +42,6 @@ export function RecordResultModal({ }) { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; - const { handleError } = useErrorHandler(); const [seafarerSearch, setSeafarerSearch] = useState(''); const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null); const [scores, setScores] = useState<Record<string, number>>({}); @@ -57,8 +55,10 @@ export function RecordResultModal({ skip: !opened, }); const [createResult, { isLoading: isSaving }] = useCreateResultMutation(); + const table = useServerTable(); const questions = exam.questions ?? []; + const pagedQuestions = table.paginate(questions); const seafarerOptions = (registrations ?? []) .filter((registration) => @@ -167,43 +167,20 @@ export function RecordResultModal({ {selectedSeafarerId && questions.length > 0 && ( <> <Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" /> - <Table striped> - <Table.Thead> - <Table.Tr> - <Table.Th>{t('result.recordModal.question')}</Table.Th> - <Table.Th>{t('result.recordModal.maxPoints')}</Table.Th> - <Table.Th>{t('result.recordModal.score')}</Table.Th> - <Table.Th>{t('result.recordModal.remark')}</Table.Th> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {questions.map((q) => ( - <Table.Tr key={q.id}> - <Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text></Table.Td> - <Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td> - <Table.Td> - <NumberInput - value={scores[q.id] ?? 0} - onChange={(v) => handleScoreChange(q.id, Number(v))} - min={0} - max={q.points} - size="xs" - style={{ width: 80 }} - /> - </Table.Td> - <Table.Td> - <TextInput - placeholder={t('result.recordModal.remarkOptional')} - value={questionRemarks[q.id] ?? ''} - onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)} - size="xs" - style={{ minWidth: 160 }} - /> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> + <AdvancedTable + tableName={t('result.recordModal.title')} + columns={recordResultColumns(t, locale, { + scores, + questionRemarks, + onScoreChange: handleScoreChange, + onRemarkChange: handleQuestionRemarkChange, + })} + data={pagedQuestions.rows} + itemCount={pagedQuestions.itemCount} + pageIndex={pagedQuestions.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + /> <Paper withBorder p="sm" radius="md" bg="gray.0"> <SimpleGrid cols={3} spacing="sm"> diff --git a/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx new file mode 100644 index 000000000..76d7b78f4 --- /dev/null +++ b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/columns.tsx @@ -0,0 +1,74 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconGavel } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { ExamAppeal } from '../../types/result'; + +export function examAppealsColumns( + t: TFunction, + locale: 'en' | 'am', + showDate: (date: string | null | undefined) => string, + handlers: { onDecide: (appeal: ExamAppeal) => void }, +): AdvancedColumn<ExamAppeal>[] { + return [ + { + header: t('result.appeals.number'), + cell: ({ row }) => ( + <Text fz="sm" ff="monospace" fw={600}> + {row.original.appealNumber} + </Text> + ), + }, + { + header: t('result.appeals.candidate'), + cell: ({ row }) => ( + <Text fz="sm"> + {row.original.profile + ? `${row.original.profile.firstName} ${row.original.profile.lastName}` + : row.original.profileId.slice(0, 8)} + </Text> + ), + }, + { + header: t('result.appeals.exam'), + cell: ({ row }) => ( + <> + <Text fz="sm"> + {row.original.result?.exam?.title?.[locale] ?? '—'} + </Text> + <Badge size="xs" variant="light" color="gray"> + {row.original.result?.status} · {row.original.result?.totalScore} + </Badge> + </> + ), + }, + { + header: t('result.appeals.reason'), + cell: ({ row }) => ( + <Text fz="xs" maw={300} lineClamp={3}> + {row.original.reason} + </Text> + ), + }, + { + header: t('result.appeals.lodged'), + cell: ({ row }) => <Text fz="xs">{showDate(row.original.createdAt)}</Text>, + }, + { + header: '', + label: t('result.appeals.decide'), + align: 'right', + cell: ({ row }) => ( + <Button + size="compact-xs" + variant="light" + color="grape" + leftSection={<IconGavel size={12} />} + onClick={() => handlers.onDecide(row.original)} + > + {t('result.appeals.decide')} + </Button> + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/result/pages/ExamAppealsPage.tsx b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/index.tsx similarity index 55% rename from apps/backoffice/src/app/features/result/pages/ExamAppealsPage.tsx rename to apps/backoffice/src/app/features/result/pages/ExamAppealsPage/index.tsx index 444aedfad..884f93d8e 100644 --- a/apps/backoffice/src/app/features/result/pages/ExamAppealsPage.tsx +++ b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage/index.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, - Badge, Button, Center, Group, @@ -11,20 +10,20 @@ import { Paper, Select, Stack, - Table, Text, Textarea, Title, } from '@mantine/core'; -import { IconGavel, IconInfoCircle } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { IconInfoCircle } from '@tabler/icons-react'; +import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage } from '@ema-platform/api'; import { useGetPendingAppealsQuery, useDecideAppealMutation, -} from '../api/result-api'; -import type { ExamAppeal } from '../types/result'; +} from '../../api/result-api'; +import { examAppealsColumns } from './columns'; +import type { ExamAppeal } from '../../types/result'; /** * The appeals desk (US-EXAM-016). @@ -37,7 +36,8 @@ export function ExamAppealsPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; const showDate = useDateDisplayer(); - const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery(); + const { data: appeals, isLoading, isError, refetch } = useGetPendingAppealsQuery(); + const table = useServerTable(); const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation(); const [target, setTarget] = useState<ExamAppeal | null>(null); @@ -75,6 +75,8 @@ export function ExamAppealsPage() { ); } + const paged = table.paginate(appeals ?? []); + return ( <Stack gap="lg"> <div> @@ -85,73 +87,23 @@ export function ExamAppealsPage() { </div> <Paper withBorder radius="md"> - {(appeals ?? []).length === 0 ? ( - <Text c="dimmed" ta="center" py="xl"> - {t('result.appeals.none')} - </Text> - ) : ( - <Table striped highlightOnHover> - <Table.Thead bg="var(--mantine-color-default-hover)"> - <Table.Tr> - <Table.Th>{t('result.appeals.number')}</Table.Th> - <Table.Th>{t('result.appeals.candidate')}</Table.Th> - <Table.Th>{t('result.appeals.exam')}</Table.Th> - <Table.Th>{t('result.appeals.reason')}</Table.Th> - <Table.Th>{t('result.appeals.lodged')}</Table.Th> - <Table.Th /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {(appeals ?? []).map((appeal) => ( - <Table.Tr key={appeal.id}> - <Table.Td> - <Text fz="sm" ff="monospace" fw={600}> - {appeal.appealNumber} - </Text> - </Table.Td> - <Table.Td> - <Text fz="sm"> - {appeal.profile - ? `${appeal.profile.firstName} ${appeal.profile.lastName}` - : appeal.profileId.slice(0, 8)} - </Text> - </Table.Td> - <Table.Td> - <Text fz="sm"> - {appeal.result?.exam?.title?.[locale] ?? '—'} - </Text> - <Badge size="xs" variant="light" color="gray"> - {appeal.result?.status} · {appeal.result?.totalScore} - </Badge> - </Table.Td> - <Table.Td> - <Text fz="xs" maw={300} lineClamp={3}> - {appeal.reason} - </Text> - </Table.Td> - <Table.Td> - <Text fz="xs">{showDate(appeal.createdAt)}</Text> - </Table.Td> - <Table.Td> - <Button - size="compact-xs" - variant="light" - color="grape" - leftSection={<IconGavel size={12} />} - onClick={() => { - setTarget(appeal); - setOutcome('UPHELD'); - setRemark(''); - }} - > - {t('result.appeals.decide')} - </Button> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - )} + <AdvancedTable<ExamAppeal> + tableName={t('result.appeals.title')} + columns={examAppealsColumns(t, locale, showDate, { + onDecide: (appeal) => { + setTarget(appeal); + setOutcome('UPHELD'); + setRemark(''); + }, + })} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + refresh={refetch} + emptyText={t('result.appeals.none')} + /> </Paper> <Modal diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx new file mode 100644 index 000000000..29cd22d22 --- /dev/null +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/actions.tsx @@ -0,0 +1,55 @@ +import { Button, Group } from '@mantine/core'; +import { IconEye, IconTrash } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Result } from '../../types/result'; + +export type QcAction = 'moderate' | 'approve' | 'return'; + +export function resultActionsColumn( + t: TFunction, + handlers: { + onQc: (result: Result, action: QcAction) => void; + onViewDetail: (result: Result) => void; + onDelete: (result: Result) => void; + }, +): AdvancedColumn<Result> { + return { + header: '', + label: t('result.columns.actions', 'Actions'), + cell: ({ row }) => { + const r = row.original; + return ( + <Group gap="xs"> + {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && ( + <> + <Button size="compact-xs" variant="light" color="yellow" onClick={() => handlers.onQc(r, 'moderate')}> + {t('result.review.moderate')} + </Button> + <Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}> + {t('result.review.approve')} + </Button> + </> + )} + {(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && ( + <Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}> + {t('result.review.return')} + </Button> + )} + <Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}> + {t('result.action.viewEdit')} + </Button> + <Button + size="xs" + variant="subtle" + color="red" + leftSection={<IconTrash size={13} />} + onClick={() => handlers.onDelete(r)} + > + {t('result.action.delete')} + </Button> + </Group> + ); + }, + }; +} diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx new file mode 100644 index 000000000..7da28a3c7 --- /dev/null +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/columns.tsx @@ -0,0 +1,78 @@ +import { Badge, Box, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Result, ResultReviewStatus } from '../../types/result'; + +export const STATUS_COLOR: Record<string, string> = { + PASSED: 'teal', + FAILED: 'red', +}; + +/** Where a mark sits in quality control (US-EXAM-011 → 014). */ +export const REVIEW_COLOR: Record<ResultReviewStatus, string> = { + MARKED: 'gray', + MODERATED: 'yellow', + APPROVED: 'blue', + PUBLISHED: 'teal', +}; + +export function resultColumns( + t: TFunction, + locale: 'en' | 'am', + showDate: (date: string) => string, + getExamTitle: (id: string) => string, +): AdvancedColumn<Result>[] { + return [ + { + header: t('result.columns.seafarer'), + cell: ({ row }) => ( + <Text fz="sm" fw={500}> + {row.original.seafarer + ? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}` + : row.original.seafarerId.slice(0, 8)} + </Text> + ), + }, + { + header: t('result.columns.exam'), + cell: ({ row }) => ( + <Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text> + ), + }, + { + header: t('result.columns.totalScore'), + cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>, + }, + { + header: t('result.columns.status'), + cell: ({ row }) => ( + <Badge + size="sm" + variant="light" + color={STATUS_COLOR[row.original.status]} + leftSection={ + <Box + w={6} + h={6} + style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }} + /> + } + > + {t(`result.status.${row.original.status}`)} + </Badge> + ), + }, + { + header: t('result.review.column'), + cell: ({ row }) => ( + <Badge size="sm" variant="light" color={REVIEW_COLOR[row.original.reviewStatus] ?? 'gray'}> + {t(`result.review.${row.original.reviewStatus}`)} + </Badge> + ), + }, + { + header: t('result.columns.date'), + cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>, + }, + ]; +} diff --git a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx similarity index 84% rename from apps/backoffice/src/app/features/result/pages/ResultPage.tsx rename to apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx index ffd8b6b94..7db5856e4 100644 --- a/apps/backoffice/src/app/features/result/pages/ResultPage.tsx +++ b/apps/backoffice/src/app/features/result/pages/ResultPage/index.tsx @@ -18,14 +18,11 @@ import { Divider, Button, ThemeIcon, - Box, TextInput, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconInfoCircle, - IconEye, - IconTrash, IconUser, IconCertificate, IconDeviceFloppy, @@ -37,7 +34,7 @@ import { IconSearch, IconSend, } from '@tabler/icons-react'; -import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui'; +import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import type { BilingualValue } from '@ema-platform/ui'; import { extractErrorMessage, useLocalized } from '@ema-platform/api'; @@ -50,26 +47,13 @@ import { useApproveResultMutation, useReturnResultMutation, usePublishExamResultsMutation, -} from '../api/result-api'; -import { useGetExamsQuery } from '../../exam/api/exam-api'; -import { RecordResultModal } from '../components/RecordResultModal'; -import type { Result, ResultBreakdown, ResultReviewStatus } from '../types/result'; -import type { Exam } from '../../exam/types/exam'; - -const STATUS_COLOR: Record<string, string> = { - PASSED: 'teal', - FAILED: 'red', -}; - -/** Where a mark sits in quality control (US-EXAM-011 → 014). */ -const REVIEW_COLOR: Record<ResultReviewStatus, string> = { - MARKED: 'gray', - MODERATED: 'yellow', - APPROVED: 'blue', - PUBLISHED: 'teal', -}; - -type QcAction = 'moderate' | 'approve' | 'return'; +} from '../../api/result-api'; +import { useGetExamsQuery } from '../../../exam/api/exam-api'; +import { RecordResultModal } from '../../components/RecordResultModal'; +import type { Result, ResultBreakdown } from '../../types/result'; +import type { Exam } from '../../../exam/types/exam'; +import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns'; +import { resultActionsColumn, type QcAction } from './actions'; function ResultStat({ label, @@ -297,96 +281,13 @@ export function ResultPage() { if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />; - const columns: AdvancedColumn<Result>[] = [ - { - header: t('result.columns.seafarer'), - cell: ({ row }) => ( - <Text fz="sm" fw={500}> - {row.original.seafarer - ? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}` - : row.original.seafarerId.slice(0, 8)} - </Text> - ), - }, - { - header: t('result.columns.exam'), - cell: ({ row }) => ( - <Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text> - ), - }, - { - header: t('result.columns.totalScore'), - cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>, - }, - { - header: t('result.columns.status'), - cell: ({ row }) => ( - <Badge - size="sm" - variant="light" - color={STATUS_COLOR[row.original.status]} - leftSection={ - <Box - w={6} - h={6} - style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }} - /> - } - > - {t(`result.status.${row.original.status}`)} - </Badge> - ), - }, - { - header: t('result.review.column'), - cell: ({ row }) => ( - <Badge size="sm" variant="light" color={REVIEW_COLOR[row.original.reviewStatus] ?? 'gray'}> - {t(`result.review.${row.original.reviewStatus}`)} - </Badge> - ), - }, - { - header: t('result.columns.date'), - cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>, - }, - { - header: '', - label: t('result.columns.actions', 'Actions'), - cell: ({ row }) => { - const r = row.original; - return ( - <Group gap="xs"> - {(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && ( - <> - <Button size="compact-xs" variant="light" color="yellow" onClick={() => openQc(r, 'moderate')}> - {t('result.review.moderate')} - </Button> - <Button size="compact-xs" variant="light" color="blue" onClick={() => openQc(r, 'approve')}> - {t('result.review.approve')} - </Button> - </> - )} - {(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && ( - <Button size="compact-xs" variant="subtle" color="orange" onClick={() => openQc(r, 'return')}> - {t('result.review.return')} - </Button> - )} - <Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => viewDetail(r)}> - {t('result.action.viewEdit')} - </Button> - <Button - size="xs" - variant="subtle" - color="red" - leftSection={<IconTrash size={13} />} - onClick={() => { setDeleteTarget(r); openDelete(); }} - > - {t('result.action.delete')} - </Button> - </Group> - ); - }, - }, + const columns = [ + ...resultColumns(t, locale, showDate, getExamTitle), + resultActionsColumn(t, { + onQc: openQc, + onViewDetail: viewDetail, + onDelete: (r) => { setDeleteTarget(r); openDelete(); }, + }), ]; const page = paginate(filtered); diff --git a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/actions.tsx b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/actions.tsx new file mode 100644 index 000000000..d4c5fed9e --- /dev/null +++ b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/actions.tsx @@ -0,0 +1,28 @@ +import { Button, Tooltip } from '@mantine/core'; +import { IconShieldCog } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { ProfileRow } from './columns'; + +export function seafarerStatusActionColumn( + t: TFunction, + handlers: { onStatus: (profile: ProfileRow) => void }, +): AdvancedColumn<ProfileRow> { + return { + header: '', + label: t('seafarerRegistry.columns.actions', 'Actions'), + cell: ({ row }) => + row.original.seafarerNumber ? ( + <Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}> + <Button + size="compact-xs" + variant="subtle" + leftSection={<IconShieldCog size={14} />} + onClick={() => handlers.onStatus(row.original)} + > + {t('seafarerRegistry.statusAction', 'Status')} + </Button> + </Tooltip> + ) : null, + }; +} diff --git a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/columns.tsx b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/columns.tsx new file mode 100644 index 000000000..cac3890d1 --- /dev/null +++ b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/columns.tsx @@ -0,0 +1,93 @@ +import { Badge, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; + +export interface ProfileRow { + id: string; + firstName: string; + middleName?: string; + lastName: string; + gender?: string; + type?: string; + isComplete?: boolean; + seafarerNumber?: string | null; + seafarerStatus?: string | null; + seafarerDepartment?: string | null; + seafarerStatusReason?: string | null; + profession?: { name?: { en?: string } }; + address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string }; +} + +export const SEAFARER_STATUS_COLORS: Record<string, string> = { + ACTIVE: 'green', + PENDING: 'yellow', + SUSPENDED: 'orange', + INACTIVE: 'gray', +}; + +export const DEPARTMENT_LABELS: Record<string, string> = { + DECK: 'Deck', + ENGINE: 'Engine', + CATERING: 'Catering', +}; + +export function seafarerRegistryColumns( + t: TFunction, + handlers: { onDetail: (profile: ProfileRow) => void }, +): AdvancedColumn<ProfileRow>[] { + return [ + { + header: t('seafarerRegistry.columns.name', 'Name'), + cell: ({ row }) => ( + <Text + size="sm" + fw={500} + style={{ cursor: 'pointer' }} + onClick={() => handlers.onDetail(row.original)} + > + {[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')} + </Text> + ), + }, + { + header: t('seafarerRegistry.columns.number', 'Seafarer №'), + cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>, + }, + { + header: t('seafarerRegistry.columns.department', 'Department'), + cell: ({ row }) => ( + <Text size="sm"> + {row.original.seafarerDepartment + ? t( + `seafarerRegistry.departments.${row.original.seafarerDepartment}`, + DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment, + ) + : '—'} + </Text> + ), + }, + { + header: t('seafarerRegistry.columns.idNumber', 'ID number'), + cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>, + }, + { + header: t('seafarerRegistry.columns.phone', 'Phone'), + cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>, + }, + { + header: t('seafarerRegistry.columns.status', 'Status'), + cell: ({ row }) => + row.original.seafarerNumber ? ( + <Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}> + {t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')} + </Badge> + ) : ( + <Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}> + {row.original.isComplete + ? t('seafarerRegistry.notRegistered', 'Not registered') + : t('seafarerRegistry.incomplete', 'Incomplete')} + </Badge> + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/index.tsx similarity index 79% rename from apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx rename to apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/index.tsx index 336953f66..c39f5c95f 100644 --- a/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage.tsx +++ b/apps/backoffice/src/app/features/seafarer-registry/pages/SeafarerRegistryPage/index.tsx @@ -17,15 +17,13 @@ import { TextInput, Textarea, Title, - Tooltip, } from '@mantine/core'; import { IconAnchor, IconSearch, - IconShieldCog, IconStethoscope, } from '@tabler/icons-react'; -import { AdvancedTable, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; +import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage, @@ -34,29 +32,13 @@ import { useGetSeaServiceForProfileQuery, useUpdateSeafarerStatusMutation, } from '@ema-platform/api'; - -interface ProfileRow { - id: string; - firstName: string; - middleName?: string; - lastName: string; - gender?: string; - type?: string; - isComplete?: boolean; - seafarerNumber?: string | null; - seafarerStatus?: string | null; - seafarerDepartment?: string | null; - seafarerStatusReason?: string | null; - profession?: { name?: { en?: string } }; - address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string }; -} - -const SEAFARER_STATUS_COLORS: Record<string, string> = { - ACTIVE: 'green', - PENDING: 'yellow', - SUSPENDED: 'orange', - INACTIVE: 'gray', -}; +import { + DEPARTMENT_LABELS, + SEAFARER_STATUS_COLORS, + seafarerRegistryColumns, + type ProfileRow, +} from './columns'; +import { seafarerStatusActionColumn } from './actions'; const RECORD_STATUS_COLORS: Record<string, string> = { SUBMITTED: 'blue', @@ -64,12 +46,6 @@ const RECORD_STATUS_COLORS: Record<string, string> = { REJECTED: 'red', }; -const DEPARTMENT_LABELS: Record<string, string> = { - DECK: 'Deck', - ENGINE: 'Engine', - CATERING: 'Catering', -}; - /** The registered seafarer's records, read-only (verification is module 06). */ function SeafarerDetailDrawer({ profile, @@ -376,77 +352,9 @@ export function SeafarerRegistryPage() { }); const page = paginate(items); - const columns: AdvancedColumn<ProfileRow>[] = [ - { - header: t('seafarerRegistry.columns.name', 'Name'), - cell: ({ row }) => ( - <Text - size="sm" - fw={500} - style={{ cursor: 'pointer' }} - onClick={() => setDetail(row.original)} - > - {[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')} - </Text> - ), - }, - { - header: t('seafarerRegistry.columns.number', 'Seafarer №'), - cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>, - }, - { - header: t('seafarerRegistry.columns.department', 'Department'), - cell: ({ row }) => ( - <Text size="sm"> - {row.original.seafarerDepartment - ? t( - `seafarerRegistry.departments.${row.original.seafarerDepartment}`, - DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment, - ) - : '—'} - </Text> - ), - }, - { - header: t('seafarerRegistry.columns.idNumber', 'ID number'), - cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>, - }, - { - header: t('seafarerRegistry.columns.phone', 'Phone'), - cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>, - }, - { - header: t('seafarerRegistry.columns.status', 'Status'), - cell: ({ row }) => - row.original.seafarerNumber ? ( - <Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}> - {t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')} - </Badge> - ) : ( - <Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}> - {row.original.isComplete - ? t('seafarerRegistry.notRegistered', 'Not registered') - : t('seafarerRegistry.incomplete', 'Incomplete')} - </Badge> - ), - }, - { - header: '', - label: t('seafarerRegistry.columns.actions', 'Actions'), - cell: ({ row }) => - row.original.seafarerNumber ? ( - <Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}> - <Button - size="compact-xs" - variant="subtle" - leftSection={<IconShieldCog size={14} />} - onClick={() => setStatusTarget(row.original)} - > - {t('seafarerRegistry.statusAction', 'Status')} - </Button> - </Tooltip> - ) : null, - }, + const columns = [ + ...seafarerRegistryColumns(t, { onDetail: setDetail }), + seafarerStatusActionColumn(t, { onStatus: setStatusTarget }), ]; return ( diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx new file mode 100644 index 000000000..fb40ed3bd --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/columns.tsx @@ -0,0 +1,95 @@ +import { Badge, Button, Text, Tooltip } from '@mantine/core'; +import { IconShieldCog } from '@tabler/icons-react'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Vessel } from '@ema-platform/api'; + +const VESSEL_STATUS_COLORS: Record<string, string> = { + REGISTERED: 'green', + SUSPENDED: 'orange', + DEREGISTERED: 'gray', +}; + +export const CATEGORY_LABELS: Record<string, string> = { + INLAND_WATERWAY: 'Inland Waterway', + SEA_GOING: 'Sea-going', +}; + +export function vesselRegistrationQueueColumns( + showDate: (value: string | number | Date | null | undefined) => string, + handlers: { + onStatus: (vessel: Vessel) => void; + }, +): AdvancedColumn<Vessel>[] { + return [ + { + header: 'Registration №', + cell: ({ row }) => ( + <Text size="sm" ff="monospace" fw={600}> + {row.original.registrationNumber} + </Text> + ), + }, + { + header: 'Vessel', + cell: ({ row }) => ( + <> + <Text size="sm" fw={500}> + {row.original.name} + </Text> + <Text size="xs" c="dimmed"> + {row.original.vesselType ?? '—'} + {row.original.imoNumber ? ` · IMO ${row.original.imoNumber}` : ''} + </Text> + </> + ), + }, + { + header: 'Category', + cell: ({ row }) => CATEGORY_LABELS[row.original.category] ?? row.original.category, + }, + { + header: 'Owner', + cell: ({ row }) => <Text size="sm">{row.original.ownerName ?? '—'}</Text>, + }, + { + header: 'Registered', + cell: ({ row }) => ( + <Text size="sm" c="dimmed"> + {showDate(row.original.registeredAt)} + </Text> + ), + }, + { + header: 'Status', + cell: ({ row }) => ( + <Badge + size="sm" + variant="light" + color={VESSEL_STATUS_COLORS[row.original.status]} + > + {row.original.status} + </Badge> + ), + }, + { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => ( + <Tooltip label="Suspend / deregister / reinstate"> + <Button + size="compact-xs" + variant="subtle" + leftSection={<IconShieldCog size={14} />} + onClick={(e) => { + e.stopPropagation(); + handlers.onStatus(row.original); + }} + > + Status + </Button> + </Tooltip> + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx similarity index 69% rename from apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage.tsx rename to apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx index b1ade8104..2df4cd9a6 100644 --- a/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage.tsx +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage/index.tsx @@ -5,7 +5,6 @@ import { Badge, Button, Card, - Center, Container, Drawer, Group, @@ -18,15 +17,13 @@ import { TextInput, Textarea, Title, - Tooltip, } from '@mantine/core'; import { IconAlertTriangle, IconInfoCircle, IconSearch, - IconShieldCog, } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage, @@ -35,17 +32,10 @@ import { useUpdateVesselStatusMutation, } from '@ema-platform/api'; import type { Vessel } from '@ema-platform/api'; - -const VESSEL_STATUS_COLORS: Record<string, string> = { - REGISTERED: 'green', - SUSPENDED: 'orange', - DEREGISTERED: 'gray', -}; - -const CATEGORY_LABELS: Record<string, string> = { - INLAND_WATERWAY: 'Inland Waterway', - SEA_GOING: 'Sea-going', -}; +import { + CATEGORY_LABELS, + vesselRegistrationQueueColumns, +} from './columns'; /** Full particulars + incident log, read-only. */ function VesselDetailDrawer({ @@ -235,7 +225,7 @@ function StatusModal({ */ export function VesselRegistrationQueuePage() { const [search, setSearch] = useState(''); - const { data, isLoading } = useGetVesselsQuery( + const { data, isLoading, refetch } = useGetVesselsQuery( search.trim() ? { search: search.trim() } : undefined, ); const [detail, setDetail] = useState<Vessel | null>(null); @@ -243,6 +233,8 @@ export function VesselRegistrationQueuePage() { const items = data?.items ?? []; const showDate = useDateDisplayer(); + const table = useServerTable(); + const paged = table.paginate(items); return ( <Container size="xl" py="md"> @@ -267,91 +259,21 @@ export function VesselRegistrationQueuePage() { /> </Group> - <Card withBorder padding={0}> - {isLoading ? ( - <Center h={200}> - <Loader /> - </Center> - ) : items.length === 0 ? ( - <Center h={160}> - <Text size="sm" c="dimmed"> - {search - ? 'No vessels match that search.' - : 'No vessels registered yet.'} - </Text> - </Center> - ) : ( - <Table highlightOnHover> - <Table.Thead> - <Table.Tr> - <Table.Th>Registration №</Table.Th> - <Table.Th>Vessel</Table.Th> - <Table.Th>Category</Table.Th> - <Table.Th>Owner</Table.Th> - <Table.Th>Registered</Table.Th> - <Table.Th>Status</Table.Th> - <Table.Th /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {items.map((vessel) => ( - <Table.Tr - key={vessel.id} - style={{ cursor: 'pointer' }} - onClick={() => setDetail(vessel)} - > - <Table.Td> - <Text size="sm" ff="monospace" fw={600}> - {vessel.registrationNumber} - </Text> - </Table.Td> - <Table.Td> - <Text size="sm" fw={500}> - {vessel.name} - </Text> - <Text size="xs" c="dimmed"> - {vessel.vesselType ?? '—'} - {vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''} - </Text> - </Table.Td> - <Table.Td> - {CATEGORY_LABELS[vessel.category] ?? vessel.category} - </Table.Td> - <Table.Td> - <Text size="sm">{vessel.ownerName ?? '—'}</Text> - </Table.Td> - <Table.Td> - <Text size="sm" c="dimmed"> - {showDate(vessel.registeredAt)} - </Text> - </Table.Td> - <Table.Td> - <Badge - size="sm" - variant="light" - color={VESSEL_STATUS_COLORS[vessel.status]} - > - {vessel.status} - </Badge> - </Table.Td> - <Table.Td onClick={(e) => e.stopPropagation()}> - <Tooltip label="Suspend / deregister / reinstate"> - <Button - size="compact-xs" - variant="subtle" - leftSection={<IconShieldCog size={14} />} - onClick={() => setStatusTarget(vessel)} - > - Status - </Button> - </Tooltip> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - )} - </Card> + <AdvancedTable + tableName="Vessel register" + columns={vesselRegistrationQueueColumns(showDate, { onStatus: setStatusTarget })} + data={paged.rows} + itemCount={paged.itemCount} + pageIndex={paged.pageIndex} + onPageChange={table.setPageIndex} + pageSize={table.pageSize} + isLoading={isLoading} + refresh={refetch} + onRowClick={(vessel) => setDetail(vessel)} + emptyText={ + search ? 'No vessels match that search.' : 'No vessels registered yet.' + } + /> <VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} /> <StatusModal diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx new file mode 100644 index 000000000..99e480e3a --- /dev/null +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage/columns.tsx @@ -0,0 +1,60 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconCertificate } from '@tabler/icons-react'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Bilingual, IssuedLicense } from '@ema-platform/api'; + +export function certificateColumns(deps: { + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + onDownload: (license: IssuedLicense) => void; +}): AdvancedColumn<IssuedLicense>[] { + return [ + { + header: 'Certificate №', + cell: ({ row }) => ( + <Text ff="monospace" size="sm" fw={600}> + {row.original.certificateNumber} + </Text> + ), + }, + { + header: 'Type', + cell: ({ row }) => deps.localized(row.original.licenseType?.name), + }, + { + header: 'Issued', + cell: ({ row }) => deps.showDate(row.original.issueDate), + }, + { + header: 'Expires', + cell: ({ row }) => deps.showDate(row.original.expiryDate), + }, + { + header: 'Status', + cell: ({ row }) => ( + <Badge + size="sm" + variant="light" + color={row.original.status === 'ACTIVE' ? 'green' : 'red'} + > + {row.original.status} + </Badge> + ), + }, + { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => ( + <Button + size="compact-xs" + variant="subtle" + leftSection={<IconCertificate size={14} />} + onClick={() => deps.onDownload(row.original)} + > + Download + </Button> + ), + }, + ]; +} diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx similarity index 75% rename from apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx rename to apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx index 5883717ac..4b02cbb4f 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage/index.tsx @@ -7,14 +7,12 @@ import { List, Loader, Stack, - Table, Text, ThemeIcon, Title, } from '@mantine/core'; import { IconArrowRight, - IconCertificate, IconCircleCheck, IconCircleX, IconInfoCircle, @@ -33,8 +31,9 @@ import { useGetMySeaTimeQuery, } from '@ema-platform/api'; import { useCurrentProfile } from '@ema-platform/auth'; -import { notify } from '@ema-platform/ui'; +import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; +import { certificateColumns } from './columns'; const CERTIFICATE_TYPE_KEYS = [ 'CERTIFICATE_OF_COMPETENCY', @@ -72,10 +71,11 @@ export function CertificatesPage() { const { data: medicals } = useGetMyMedicalCertificatesQuery(); const { data: applications, isLoading: loadingApplications } = useGetMyApplicationsQuery(); - const { data: licenses } = useGetMyLicensesQuery(); + const { data: licenses, refetch: refetchLicenses } = useGetMyLicensesQuery(); const [getCertificateUrl] = useGetCertificateUrlMutation(); const showDate = useDateDisplayer(); const localized = useLocalized(); + const issuedTable = useServerTable(); const registered = Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE'; @@ -113,6 +113,8 @@ export function CertificatesPage() { ); } + const pagedIssued = issuedTable.paginate(issued); + return ( <Stack maw={860} mx="auto"> <Title order={2}>My Certificates @@ -220,61 +222,21 @@ export function CertificatesPage() { Issued certificates - {issued.length === 0 ? ( - - - No certificates issued yet. - - - ) : ( - - - - - Certificate № - Type - Issued - Expires - Status - - - - - {issued.map((license) => ( - - - - {license.certificateNumber} - - - {localized(license.licenseType?.name)} - {showDate(license.issueDate)} - {showDate(license.expiryDate)} - - - {license.status} - - - - - - - ))} - -
-
- )} + download(license.id), + })} + data={pagedIssued.rows} + itemCount={pagedIssued.itemCount} + pageIndex={pagedIssued.pageIndex} + onPageChange={issuedTable.setPageIndex} + pageSize={issuedTable.pageSize} + refresh={refetchLicenses} + emptyText="No certificates issued yet." + />
); diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx new file mode 100644 index 000000000..8b7158139 --- /dev/null +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/columns.tsx @@ -0,0 +1,52 @@ +import { Badge, Progress, Text } from '@mantine/core'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import { + STATUS_COLORS, + STATUS_LABELS, + STATUS_PROGRESS, + localized, +} from '@ema-platform/api'; +import type { LicenseApplication } from '@ema-platform/api'; + +export const dashboardApplicationColumns: AdvancedColumn[] = + [ + { + header: 'Application', + cell: ({ row }) => ( + <> + + {row.original.applicationNumber} + + + {row.original.companyName ?? '—'} + + + ), + }, + { + header: 'Licence', + cell: ({ row }) => ( + {localized(row.original.licenseType?.name) || '—'} + ), + }, + { + header: 'Status', + cell: ({ row }) => ( + + {STATUS_LABELS[row.original.status]} + + ), + }, + { + header: 'Progress', + size: 180, + cell: ({ row }) => ( + + ), + }, + ]; diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx similarity index 85% rename from apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx rename to apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx index 2ed1fa9c9..f7ed4a652 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage/index.tsx @@ -4,7 +4,6 @@ import { useSelector } from 'react-redux'; import { Alert, Anchor, - Badge, Box, Button, Card, @@ -13,10 +12,8 @@ import { Group, Loader, Paper, - Progress, SimpleGrid, Stack, - Table, Text, ThemeIcon, Title, @@ -30,21 +27,20 @@ import { IconFileText, IconShieldCheck, } from '@tabler/icons-react'; -import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge'; +import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge'; import { APPLICANT_ACTION_STATUSES, STATUS_COLORS, - STATUS_LABELS, - STATUS_PROGRESS, TERMINAL_STATUSES, - useLocalized, useGetCertificateUrlMutation, useGetMyApplicationsQuery, useGetMyLicensesQuery, } from '@ema-platform/api'; +import { AdvancedTable, useServerTable } from '@ema-platform/ui'; import type { IssuedLicense, LicenseApplication } from '@ema-platform/api'; -import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue'; -import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard'; +import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue'; +import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard'; +import { dashboardApplicationColumns } from './columns'; /** * The applicant's home screen. @@ -82,7 +78,7 @@ export function DashboardPage() { state.auth.user?.name?.en || state.auth.user?.username || '', ); - const { data: applications, isLoading } = useGetMyApplicationsQuery(); + const { data: applications, isLoading, refetch } = useGetMyApplicationsQuery(); const { data: licenses } = useGetMyLicensesQuery(); const [getCertificateUrl, { isLoading: isDownloading }] = useGetCertificateUrlMutation(); @@ -207,6 +203,7 @@ export function DashboardPage() { )} @@ -453,68 +450,33 @@ function Section({ function ApplicationTable({ applications, navigate, + onRefresh, }: { applications: LicenseApplication[]; navigate: (path: string) => void; + onRefresh: () => void; }) { - const localized = useLocalized(); + const table = useServerTable(); + const paged = table.paginate(applications); return ( - - - - - - Application - Licence - Status - Progress - - - - {applications.map((app) => ( - - navigate( - app.licenseType?.key - ? `/licensing/${app.licenseType.key}/applications/${app.id}` - : '/licensing/applications', - ) - } - > - - - {app.applicationNumber} - - - {app.companyName ?? '—'} - - - - - {localized(app.licenseType?.name) || '—'} - - - - - {STATUS_LABELS[app.status]} - - - - - - - ))} - -
-
-
+ + navigate( + app.licenseType?.key + ? `/licensing/${app.licenseType.key}/applications/${app.id}` + : '/licensing/applications', + ) + } + /> ); } diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx new file mode 100644 index 000000000..298edc6a0 --- /dev/null +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx @@ -0,0 +1,145 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconFileText, IconGavel } from '@tabler/icons-react'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Bilingual } from '@ema-platform/api'; +import type { + AttendanceStatus, + MyAppeal, + MyRegistration, + MyResult, +} from './index'; + +const ATTENDANCE_COLOR: Record = { + REGISTERED: 'gray', + PRESENT: 'teal', + LATE: 'yellow', + ABSENT: 'red', + WITHDRAWN: 'orange', + DISQUALIFIED: 'red', +}; + +export function registrationColumns(deps: { + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + onDownloadSlip: (registration: MyRegistration) => void; +}): AdvancedColumn[] { + return [ + { + header: 'Admission №', + cell: ({ row }) => ( + + {row.original.admissionNumber} + + ), + }, + { + header: 'Examination', + cell: ({ row }) => deps.localized(row.original.exam?.title) || '—', + }, + { + header: 'Date', + cell: ({ row }) => deps.showDate(row.original.exam?.date), + }, + { + header: 'Venue', + cell: ({ row }) => row.original.exam?.venue ?? '—', + }, + { + header: 'Attempt', + cell: ({ row }) => ( + + {row.original.kind === 'RETAKE' + ? `Retake · ${row.original.attemptNumber}` + : 'First sitting'} + + ), + }, + { + header: 'Attendance', + cell: ({ row }) => ( + + {row.original.attendanceStatus} + + ), + }, + { + header: 'Slip', + cell: ({ row }) => ( + + ), + }, + ]; +} + +export function resultColumns(deps: { + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + appeals: MyAppeal[]; + onAppeal: (result: MyResult) => void; +}): AdvancedColumn[] { + return [ + { + header: 'Examination', + cell: ({ row }) => deps.localized(row.original.exam?.title) || '—', + }, + { + header: 'Published', + cell: ({ row }) => deps.showDate(row.original.publishedAt), + }, + { + header: 'Score', + cell: ({ row }) => ( + + {row.original.totalScore} + + ), + }, + { + header: 'Outcome', + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + header: 'Appeal', + cell: ({ row }) => { + const appeal = deps.appeals.find((a) => a.resultId === row.original.id); + return appeal ? ( + + {appeal.appealNumber} · {appeal.status} + + ) : ( + + ); + }, + }, + ]; +} diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx similarity index 55% rename from apps/portal/src/app/features/exams/pages/ExamsPage.tsx rename to apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx index f49865d3b..993b8c7d9 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx @@ -7,13 +7,12 @@ import { Loader, Modal, Stack, - Table, Text, Textarea, Title, } from '@mantine/core'; -import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { IconClipboardList } from '@tabler/icons-react'; +import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { useApiQuery, @@ -22,8 +21,9 @@ import { openAuthedDocument, useLocalized, } from '@ema-platform/api'; +import { registrationColumns, resultColumns } from './columns'; -interface OpenExam { +export interface OpenExam { id: string; title: { en?: string; am?: string }; date: string; @@ -32,7 +32,7 @@ interface OpenExam { certification?: { name?: { en?: string } }; } -type AttendanceStatus = +export type AttendanceStatus = | 'REGISTERED' | 'PRESENT' | 'ABSENT' @@ -40,7 +40,7 @@ type AttendanceStatus = | 'WITHDRAWN' | 'DISQUALIFIED'; -interface MyRegistration { +export interface MyRegistration { id: string; admissionNumber: string; createdAt: string; @@ -50,7 +50,7 @@ interface MyRegistration { exam?: OpenExam; } -interface MyResult { +export interface MyResult { id: string; totalScore: number; status: 'PASSED' | 'FAILED'; @@ -59,7 +59,7 @@ interface MyResult { exam?: OpenExam; } -interface MyAppeal { +export interface MyAppeal { id: string; appealNumber: string; status: 'SUBMITTED' | 'UNDER_REVIEW' | 'UPHELD' | 'REJECTED'; @@ -68,15 +68,6 @@ interface MyAppeal { resultId: string; } -const ATTENDANCE_COLOR: Record = { - REGISTERED: 'gray', - PRESENT: 'teal', - LATE: 'yellow', - ABSENT: 'red', - WITHDRAWN: 'orange', - DISQUALIFIED: 'red', -}; - /** * The candidate's examination home (US-EXAM-007/008/014/015/016): sessions to * sit, the admission slip to print, published results, and the appeal route @@ -100,7 +91,11 @@ export function ExamsPage() { url: '/exams/registrations/mine', method: 'GET', }); - const { data: results, isLoading: loadingResults } = useApiQuery({ + const { + data: results, + isLoading: loadingResults, + refetch: refetchResults, + } = useApiQuery({ url: '/results/mine', method: 'GET', }); @@ -110,6 +105,8 @@ export function ExamsPage() { } = useApiQuery({ url: '/results/appeals/mine', method: 'GET' }); const [registerTrigger, { isLoading: registering }] = useApiMutation(); const [appealTrigger, { isLoading: appealing }] = useApiMutation(); + const registrationTable = useServerTable(); + const resultTable = useServerTable(); const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id)); @@ -182,6 +179,9 @@ export function ExamsPage() { ); } + const pagedRegistrations = registrationTable.paginate(mine ?? []); + const pagedResults = resultTable.paginate(results ?? []); + return ( Examinations @@ -228,145 +228,41 @@ export function ExamsPage() { My registrations - {(mine ?? []).length === 0 ? ( - - - No exam registrations yet. - - - ) : ( - - - - - Admission № - Examination - Date - Venue - Attempt - Attendance - Slip - - - - {(mine ?? []).map((registration) => ( - - - - {registration.admissionNumber} - - - {localized(registration.exam?.title) || '—'} - {showDate(registration.exam?.date)} - {registration.exam?.venue ?? '—'} - - - {registration.kind === 'RETAKE' - ? `Retake · ${registration.attemptNumber}` - : 'First sitting'} - - - - - {registration.attendanceStatus} - - - - - - - ))} - -
-
- )} + + tableName="My registrations" + columns={registrationColumns({ + localized, + showDate, + onDownloadSlip: downloadSlip, + })} + data={pagedRegistrations.rows} + itemCount={pagedRegistrations.itemCount} + pageIndex={pagedRegistrations.pageIndex} + onPageChange={registrationTable.setPageIndex} + pageSize={registrationTable.pageSize} + refresh={refetch} + emptyText="No exam registrations yet." + />
My results - {(results ?? []).length === 0 ? ( - - - No results have been published yet. Marks appear here once the - authority approves and publishes them. - - - ) : ( - - - - - Examination - Published - Score - Outcome - Appeal - - - - {(results ?? []).map((result) => { - const appeal = (appeals ?? []).find( - (a) => a.resultId === result.id, - ); - return ( - - {localized(result.exam?.title) || '—'} - {showDate(result.publishedAt)} - - - {result.totalScore} - - - - - {result.status} - - - - {appeal ? ( - - {appeal.appealNumber} · {appeal.status} - - ) : ( - - )} - - - ); - })} - -
-
- )} + + tableName="My results" + columns={resultColumns({ + localized, + showDate, + appeals: appeals ?? [], + onAppeal: setAppealFor, + })} + data={pagedResults.rows} + itemCount={pagedResults.itemCount} + pageIndex={pagedResults.pageIndex} + onPageChange={resultTable.setPageIndex} + pageSize={resultTable.pageSize} + refresh={refetchResults} + emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them." + />
void; + onCertificate: (app: LicenseApplication) => void; + onPay: (app: LicenseApplication) => void; + onOpen: (app: LicenseApplication) => void; + }, +): AdvancedColumn { + return { + header: '', + label: t('common.actions'), + align: 'right', + cell: ({ row }) => { + const app = row.original; + return ( + + {deps.bypassEnabled && app.status === 'PAYMENT_PENDING' && ( + + )} + {/* An issued application's primary action is the certificate. It + used to be "View", which opened the application wizard — so + the one thing the applicant came back for was the one thing + the button did not do. */} + {app.status === 'CERTIFICATE_ISSUED' && ( + + )} + + + ); + }, + }; +} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/columns.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/columns.tsx new file mode 100644 index 000000000..81dd9fef9 --- /dev/null +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/columns.tsx @@ -0,0 +1,70 @@ +import { Badge, Box, Progress, Text } from '@mantine/core'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import { + STATUS_COLORS, + STATUS_PROGRESS, + applicantOrCompanyName, + localized, + type LicenseApplication, + type LicenseStatus, +} from '@ema-platform/api'; + +export function applicationColumns( + t: TFunction, + deps: { + language: string; + showDate: (date: string) => string; + statusLabel: (status: LicenseStatus) => string; + }, +): AdvancedColumn[] { + return [ + { + header: t('applications.table.licence'), + cell: ({ row }) => ( + + + {localized(row.original.licenseType?.name, deps.language) || '—'} + + + {row.original.applicationNumber} + + + ), + }, + { + header: t('applications.table.applicant'), + cell: ({ row }) => {applicantOrCompanyName(row.original) ?? '—'}, + }, + { + header: t('common.status'), + cell: ({ row }) => ( + + {deps.statusLabel(row.original.status)} + + ), + }, + { + header: t('applications.table.progress'), + size: 140, + cell: ({ row }) => ( + + ), + }, + { + header: t('common.date'), + cell: ({ row }) => ( + + {row.original.submittedAt + ? deps.showDate(row.original.submittedAt) + : t('applications.card.notFiled')} + + ), + }, + ]; +} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx similarity index 77% rename from apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx rename to apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx index fe69a7168..c1912a4e6 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx @@ -3,14 +3,12 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Alert, - Badge, Box, Button, Card, Container, Group, Paper, - Progress, Select, SimpleGrid, Skeleton, @@ -26,35 +24,33 @@ import { IconCertificate, IconClipboardList, IconClockHour4, - IconDownload, IconFileText, IconPlus, IconSearch, IconX, } from '@tabler/icons-react'; -import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; +import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; -import { LicenseCatalogue } from '../components/LicenseCatalogue'; -import { LicenseCard, useRenewLicense } from '../components/LicenseCard'; -import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment'; +import { LicenseCatalogue } from '../../components/LicenseCatalogue'; +import { LicenseCard, useRenewLicense } from '../../components/LicenseCard'; +import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment'; import { notifications } from '@mantine/notifications'; import { APPLICANT_ACTION_STATUSES, STATUS_COLORS, - STATUS_PROGRESS, TERMINAL_STATUSES, applicantOrCompanyName, extractErrorMessage, - localized, useBypassPaymentMutation, useGetCertificateUrlMutation, useGetMyApplicationsQuery, useGetMyLicensesQuery, useGetPaymentCapabilitiesQuery, - type LicenseApplication, type LicenseStatus, } from '@ema-platform/api'; -import classes from './MyApplicationsPage.module.css'; +import { applicationColumns } from './columns'; +import { applicationActionsColumn } from './actions'; +import classes from '../MyApplicationsPage.module.css'; /** Days before expiry at which a licence is worth flagging. */ const EXPIRY_WARNING_DAYS = 60; @@ -238,124 +234,18 @@ export function MyApplicationsPage() { const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[]; - const applicationColumns: AdvancedColumn[] = [ - { - header: t('applications.table.licence'), - cell: ({ row }) => ( - - - {localized(row.original.licenseType?.name, i18n.language) || '—'} - - - {row.original.applicationNumber} - - - ), - }, - { - header: t('applications.table.applicant'), - cell: ({ row }) => {applicantOrCompanyName(row.original) ?? '—'}, - }, - { - header: t('common.status'), - cell: ({ row }) => ( - - {statusLabel(row.original.status)} - - ), - }, - { - header: t('applications.table.progress'), - size: 140, - cell: ({ row }) => ( - - ), - }, - { - header: t('common.date'), - cell: ({ row }) => ( - - {row.original.submittedAt - ? showDate(row.original.submittedAt) - : t('applications.card.notFiled')} - - ), - }, - { - header: '', - label: t('common.actions'), - align: 'right', - cell: ({ row }) => { - const app = row.original; - return ( - - {capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && ( - - )} - {/* An issued application's primary action is the certificate. It - used to be "View", which opened the application wizard — so - the one thing the applicant came back for was the one thing - the button did not do. */} - {app.status === 'CERTIFICATE_ISSUED' && ( - - )} - - - ); - }, - }, + const columns = [ + ...applicationColumns(t, { language: i18n.language, showDate, statusLabel }), + applicationActionsColumn(t, { + bypassEnabled: capabilities?.bypassEnabled ?? false, + bypassing, + isPaying, + onBypass: (app) => handleBypass(app.id), + onCertificate: (app) => openCertificateForApplication(app.id), + onPay: (app) => pay(app.id), + onOpen: (app) => + navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`), + }), ]; return ( @@ -497,7 +387,7 @@ export function MyApplicationsPage() { /> ) : ( void; + onEdit: (record: SeaServiceRecord) => void; + onDelete: (record: SeaServiceRecord) => void; +}): AdvancedColumn { + return { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => { + const record = row.original; + const locked = record.status !== 'SUBMITTED'; + return ( + + + handlers.onEvidence(record)} + > + + + + + handlers.onEdit(record)} + > + + + + + handlers.onDelete(record)} + > + + + + + ); + }, + }; +} + +export function medicalActionsColumn(handlers: { + onEvidence: (certificate: MedicalCertificate) => void; + onEdit: (certificate: MedicalCertificate) => void; + onDelete: (certificate: MedicalCertificate) => void; +}): AdvancedColumn { + return { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => { + const certificate = row.original; + const locked = certificate.status !== 'SUBMITTED'; + return ( + + + handlers.onEvidence(certificate)} + > + + + + + handlers.onEdit(certificate)} + > + + + + + handlers.onDelete(certificate)} + > + + + + + ); + }, + }; +} diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx new file mode 100644 index 000000000..c51abaeb2 --- /dev/null +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/columns.tsx @@ -0,0 +1,117 @@ +import { Badge, Group, Text, Tooltip } from '@mantine/core'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; + +const RECORD_STATUS_COLORS: Record = { + SUBMITTED: 'blue', + VERIFIED: 'green', + REJECTED: 'red', +}; + +export const FITNESS_OPTIONS = [ + { value: 'FIT', label: 'Fit' }, + { value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' }, + { value: 'UNFIT', label: 'Unfit' }, +]; + +export function seaServiceColumns( + showDate: (date: string) => string, +): AdvancedColumn[] { + return [ + { + header: 'Vessel', + cell: ({ row }) => ( + <> + + {row.original.vesselName} + + {row.original.imoNumber && ( + + IMO {row.original.imoNumber} + + )} + + ), + }, + { header: 'Rank', accessorKey: 'rank' }, + { + header: 'From', + accessorKey: 'engagementDate', + cell: ({ row }) => showDate(row.original.engagementDate), + }, + { + header: 'To', + accessorKey: 'dischargeDate', + cell: ({ row }) => showDate(row.original.dischargeDate), + }, + { + header: 'Status', + cell: ({ row }) => ( + + + {row.original.status} + + + ), + }, + ]; +} + +export function medicalColumns( + showDate: (date: string) => string, +): AdvancedColumn[] { + const today = new Date().toISOString().slice(0, 10); + return [ + { + header: 'Issuer', + cell: ({ row }) => ( + <> + + {row.original.issuerName} + + {row.original.certificateNumber && ( + + № {row.original.certificateNumber} + + )} + + ), + }, + { + header: 'Issued', + accessorKey: 'issueDate', + cell: ({ row }) => showDate(row.original.issueDate), + }, + { + header: 'Expires', + cell: ({ row }) => ( + + {showDate(row.original.expiryDate)} + {row.original.expiryDate < today && Expired} + + ), + }, + { + header: 'Fitness', + cell: ({ row }) => + FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus) + ?.label ?? row.original.fitnessStatus, + }, + { + header: 'Status', + cell: ({ row }) => ( + + + {row.original.status} + + + ), + }, + ]; +} diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx similarity index 77% rename from apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx rename to apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx index 638abe10d..9db908f57 100644 --- a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage/index.tsx @@ -1,5 +1,4 @@ import { - ActionIcon, Alert, Anchor, Badge, @@ -18,20 +17,17 @@ import { TextInput, Textarea, Title, - Tooltip, } from '@mantine/core'; import { IconAnchor, - IconEdit, IconFileUpload, IconInfoCircle, IconPaperclip, IconPlus, IconStethoscope, - IconTrash, } from '@tabler/icons-react'; import { useState } from 'react'; -import { AdvancedTable, AmharicDatePicker, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; +import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage, @@ -48,18 +44,8 @@ import { useUpdateSeaServiceRecordMutation, } from '@ema-platform/api'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; - -const RECORD_STATUS_COLORS: Record = { - SUBMITTED: 'blue', - VERIFIED: 'green', - REJECTED: 'red', -}; - -const FITNESS_OPTIONS = [ - { value: 'FIT', label: 'Fit' }, - { value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' }, - { value: 'UNFIT', label: 'Unfit' }, -]; +import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns'; +import { seaServiceActionsColumn, medicalActionsColumn } from './actions'; /** * Evidence viewer/uploader shared by both record kinds. @@ -242,86 +228,13 @@ function SeaServiceTab() { const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 }); const page = paginate(records ?? []); - const columns: AdvancedColumn[] = [ - { - header: 'Vessel', - cell: ({ row }) => ( - <> - - {row.original.vesselName} - - {row.original.imoNumber && ( - - IMO {row.original.imoNumber} - - )} - - ), - }, - { header: 'Rank', accessorKey: 'rank' }, - { - header: 'From', - accessorKey: 'engagementDate', - cell: ({ row }) => showDate(row.original.engagementDate), - }, - { - header: 'To', - accessorKey: 'dischargeDate', - cell: ({ row }) => showDate(row.original.dischargeDate), - }, - { - header: 'Status', - cell: ({ row }) => ( - - - {row.original.status} - - - ), - }, - { - header: '', - label: 'Actions', - align: 'right', - cell: ({ row }) => { - const record = row.original; - const locked = record.status !== 'SUBMITTED'; - return ( - - - setEvidenceFor(record.id)} - > - - - - - openEdit(record)} - > - - - - - remove(record)} - > - - - - - ); - }, - }, + const columns = [ + ...seaServiceColumns(showDate), + seaServiceActionsColumn({ + onEvidence: (record) => setEvidenceFor(record.id), + onEdit: openEdit, + onDelete: remove, + }), ]; return ( @@ -547,100 +460,16 @@ function MedicalTab() { form.expiryDate && form.issueDate < form.expiryDate; - const today = new Date().toISOString().slice(0, 10); - const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 }); const page = paginate(certificates ?? []); - const columns: AdvancedColumn[] = [ - { - header: 'Issuer', - cell: ({ row }) => ( - <> - - {row.original.issuerName} - - {row.original.certificateNumber && ( - - № {row.original.certificateNumber} - - )} - - ), - }, - { - header: 'Issued', - accessorKey: 'issueDate', - cell: ({ row }) => showDate(row.original.issueDate), - }, - { - header: 'Expires', - cell: ({ row }) => ( - - {showDate(row.original.expiryDate)} - {row.original.expiryDate < today && Expired} - - ), - }, - { - header: 'Fitness', - cell: ({ row }) => - FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus) - ?.label ?? row.original.fitnessStatus, - }, - { - header: 'Status', - cell: ({ row }) => ( - - - {row.original.status} - - - ), - }, - { - header: '', - label: 'Actions', - align: 'right', - cell: ({ row }) => { - const certificate = row.original; - const locked = certificate.status !== 'SUBMITTED'; - return ( - - - setEvidenceFor(certificate.id)} - > - - - - - openEdit(certificate)} - > - - - - - remove(certificate)} - > - - - - - ); - }, - }, + const columns = [ + ...medicalColumns(showDate), + medicalActionsColumn({ + onEvidence: (certificate) => setEvidenceFor(certificate.id), + onEdit: openEdit, + onDelete: remove, + }), ]; return ( diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx new file mode 100644 index 000000000..82496bb7c --- /dev/null +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/columns.tsx @@ -0,0 +1,143 @@ +import { Badge, Button, Group, Text, Tooltip } from '@mantine/core'; +import { + IconAlertTriangle, + IconCertificate, + IconRefresh, +} from '@tabler/icons-react'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { IssuedLicense, Vessel } from '@ema-platform/api'; + +const CATEGORY_LABELS: Record = { + INLAND_WATERWAY: 'Inland Waterway', + SEA_GOING: 'Sea-going', +}; + +const VESSEL_STATUS_COLORS: Record = { + REGISTERED: 'green', + SUSPENDED: 'orange', + DEREGISTERED: 'gray', +}; + +export function vesselColumns(handlers: { + licenseById: Map; + onDownloadCertificate: (vessel: Vessel) => void; + onRenew: (vessel: Vessel) => void; + onReportIncident: (vessel: Vessel) => void; +}): AdvancedColumn[] { + return [ + { + header: 'Registration №', + cell: ({ row }) => ( + + {row.original.registrationNumber} + + ), + }, + { + header: 'Vessel', + cell: ({ row }) => ( + <> + + {row.original.name} + + + {row.original.vesselType ?? '—'} + {row.original.imoNumber ? ` · IMO ${row.original.imoNumber}` : ''} + + + ), + }, + { + header: 'Category', + cell: ({ row }) => + CATEGORY_LABELS[row.original.category] ?? row.original.category, + }, + { + header: 'Certificate', + cell: ({ row }) => { + const license = handlers.licenseById.get(row.original.licenseId); + const expiring = + license?.daysUntilExpiry !== undefined && + license.daysUntilExpiry <= 60; + return license ? ( + + + {license.status === 'ACTIVE' && expiring + ? `Expires in ${license.daysUntilExpiry}d` + : license.status} + + + ) : ( + + — + + ); + }, + }, + { + header: 'Status', + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + header: '', + label: 'Actions', + align: 'right', + cell: ({ row }) => { + const vessel = row.original; + const renewable = + handlers.licenseById.get(vessel.licenseId)?.renewable ?? false; + return ( + + + + + {renewable && vessel.status === 'REGISTERED' && ( + + + + )} + + + + + ); + }, + }, + ]; +} diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx similarity index 62% rename from apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx rename to apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx index 065f83b4e..f8fb59d33 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage/index.tsx @@ -11,24 +11,19 @@ import { Paper, Progress, Stack, - Table, Text, TextInput, Textarea, Title, - Tooltip, } from '@mantine/core'; import { - IconAlertTriangle, IconAnchor, IconArrowRight, - IconCertificate, IconInfoCircle, IconPlus, - IconRefresh, IconShip, } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { STATUS_COLORS, STATUS_LABELS, @@ -43,20 +38,10 @@ import { useGetMyVesselsQuery, } from '@ema-platform/api'; import type { Vessel } from '@ema-platform/api'; +import { vesselColumns } from './columns'; const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION'; -const CATEGORY_LABELS: Record = { - INLAND_WATERWAY: 'Inland Waterway', - SEA_GOING: 'Sea-going', -}; - -const VESSEL_STATUS_COLORS: Record = { - REGISTERED: 'green', - SUSPENDED: 'orange', - DEREGISTERED: 'gray', -}; - /** US-VES-016: the owner reports an accident or incident on their vessel. */ function IncidentModal({ vessel, @@ -142,13 +127,15 @@ function IncidentModal({ */ export function VesselRegistrationPage() { const navigate = useNavigate(); - const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery(); + const { data: vessels, isLoading: loadingVessels, refetch } = useGetMyVesselsQuery(); const { data: applications, isLoading: loadingApplications } = useGetMyApplicationsQuery(); const { data: licenses } = useGetMyLicensesQuery(); const [createApplication] = useCreateApplicationMutation(); const [getCertificateUrl] = useGetCertificateUrlMutation(); const [incidentFor, setIncidentFor] = useState(null); + const table = useServerTable(); + const pagedVessels = table.paginate(vessels ?? []); const inFlight = (applications?.items ?? []).filter( (app) => @@ -286,120 +273,21 @@ export function VesselRegistrationPage() {
) : ( - - - - - Registration № - Vessel - Category - Certificate - Status - - - - - {(vessels ?? []).map((vessel) => { - const license = licenseById.get(vessel.licenseId); - const renewable = license?.renewable ?? false; - const expiring = - license?.daysUntilExpiry !== undefined && - license.daysUntilExpiry <= 60; - return ( - - - - {vessel.registrationNumber} - - - - - {vessel.name} - - - {vessel.vesselType ?? '—'} - {vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''} - - - - {CATEGORY_LABELS[vessel.category] ?? vessel.category} - - - {license ? ( - - - {license.status === 'ACTIVE' && expiring - ? `Expires in ${license.daysUntilExpiry}d` - : license.status} - - - ) : ( - - — - - )} - - - - {vessel.status} - - - - - - - - {renewable && vessel.status === 'REGISTERED' && ( - - - - )} - - - - - - - ); - })} - -
-
+ )} diff --git a/apps/portal/src/app/features/waiver/pages/WaiverPage/columns.tsx b/apps/portal/src/app/features/waiver/pages/WaiverPage/columns.tsx new file mode 100644 index 000000000..6aa7becc4 --- /dev/null +++ b/apps/portal/src/app/features/waiver/pages/WaiverPage/columns.tsx @@ -0,0 +1,59 @@ +import { Badge, Button, Text } from '@mantine/core'; +import { IconFileText } from '@tabler/icons-react'; +import type { TFunction } from 'i18next'; +import type { AdvancedColumn } from '@ema-platform/ui'; +import type { Bilingual, IssuedLicense } from '@ema-platform/api'; + +export function waiverLetterColumns(deps: { + t: TFunction; + localized: (value: Bilingual | undefined) => string; + showDate: (value: string | null | undefined) => string; + onDownload: (license: IssuedLicense) => void; +}): AdvancedColumn[] { + const { t } = deps; + return [ + { + header: t('waiver.columns.reference', 'Reference'), + cell: ({ row }) => ( + + {row.original.certificateNumber} + + ), + }, + { + header: t('waiver.columns.kind', 'Kind'), + cell: ({ row }) => deps.localized(row.original.licenseType?.name) || '—', + }, + { + header: t('waiver.columns.issued', 'Issued'), + cell: ({ row }) => deps.showDate(row.original.issueDate), + }, + { + header: t('waiver.columns.status', 'Status'), + cell: ({ row }) => ( + + {t(`waiver.licenseStatus.${row.original.status}`, row.original.status)} + + ), + }, + { + header: '', + label: t('waiver.columns.actions', 'Actions'), + align: 'right', + cell: ({ row }) => ( + + ), + }, + ]; +} diff --git a/apps/portal/src/app/features/waiver/pages/WaiverPage.tsx b/apps/portal/src/app/features/waiver/pages/WaiverPage/index.tsx similarity index 70% rename from apps/portal/src/app/features/waiver/pages/WaiverPage.tsx rename to apps/portal/src/app/features/waiver/pages/WaiverPage/index.tsx index c6ae97e36..34f708f9b 100644 --- a/apps/portal/src/app/features/waiver/pages/WaiverPage.tsx +++ b/apps/portal/src/app/features/waiver/pages/WaiverPage/index.tsx @@ -7,15 +7,10 @@ import { Loader, SimpleGrid, Stack, - Table, Text, Title, } from '@mantine/core'; -import { - IconArrowRight, - IconFileText, - IconInfoCircle, -} from '@tabler/icons-react'; +import { IconArrowRight, IconInfoCircle } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -28,8 +23,9 @@ import { useGetMyApplicationsQuery, useGetMyLicensesQuery, } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; +import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui'; import { useDateDisplayer } from '@ema-platform/shared'; +import { waiverLetterColumns } from './columns'; const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER']; @@ -45,10 +41,11 @@ export function WaiverPage() { const { t } = useTranslation(); const navigate = useNavigate(); const { data: applications, isLoading } = useGetMyApplicationsQuery(); - const { data: licenses } = useGetMyLicensesQuery(); + const { data: licenses, refetch } = useGetMyLicensesQuery(); const [getCertificateUrl] = useGetCertificateUrlMutation(); const showDate = useDateDisplayer(); const localized = useLocalized(); + const lettersTable = useServerTable(); const waiverApplications = (applications?.items ?? []).filter((app) => WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''), @@ -77,6 +74,8 @@ export function WaiverPage() { ); } + const pagedLetters = lettersTable.paginate(letters); + return (
@@ -169,59 +168,22 @@ export function WaiverPage() { {t('waiver.issuedLetters', 'Issued waiver letters')} - {letters.length === 0 ? ( - - - {t('waiver.emptyLetters', 'No waiver letters issued yet.')} - - - ) : ( - - - - - {t('waiver.columns.reference', 'Reference')} - {t('waiver.columns.kind', 'Kind')} - {t('waiver.columns.issued', 'Issued')} - {t('waiver.columns.status', 'Status')} - - - - - {letters.map((license) => ( - - - - {license.certificateNumber} - - - {localized(license.licenseType?.name) || '—'} - {showDate(license.issueDate)} - - - {t(`waiver.licenseStatus.${license.status}`, license.status)} - - - - - - - ))} - -
-
- )} + download(license.id), + })} + data={pagedLetters.rows} + itemCount={pagedLetters.itemCount} + pageIndex={pagedLetters.pageIndex} + onPageChange={lettersTable.setPageIndex} + pageSize={lettersTable.pageSize} + refresh={refetch} + emptyText={t('waiver.emptyLetters', 'No waiver letters issued yet.')} + />
); diff --git a/libs/ui/src/lib/data/AdvancedTable.tsx b/libs/ui/src/lib/data/AdvancedTable.tsx index a50010013..53a5a54cd 100644 --- a/libs/ui/src/lib/data/AdvancedTable.tsx +++ b/libs/ui/src/lib/data/AdvancedTable.tsx @@ -49,6 +49,8 @@ interface AdvancedTableProps { emptyText?: string; verticalSpacing?: string | number; rowStyle?: (row: T, index: number) => CSSProperties | undefined; + /** Makes rows clickable (adds pointer cursor). */ + onRowClick?: (row: T) => void; } function getByPath(obj: unknown, path?: string): unknown { @@ -79,6 +81,7 @@ export function AdvancedTable({ emptyText, verticalSpacing = "sm", rowStyle, + onRowClick, }: AdvancedTableProps) { const { t } = useTranslation(); const [visible, setVisible] = useState( @@ -202,7 +205,14 @@ export function AdvancedTable({ ) : ( data.map((row, rowIndex) => ( - + onRowClick(row) : undefined} + style={{ + ...(onRowClick ? { cursor: "pointer" } : undefined), + ...rowStyle?.(row, rowIndex), + }} + > {shownColumns.map((col, i) => { const value = getByPath(row, col.accessorKey); return (