From ed782237caea4d0c28245a5150fd35c938806f84 Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:17:00 +0300 Subject: [PATCH] fixes --- .../license-review/pages/LicenseQueuePage.tsx | 370 +++++++++++------- 1 file changed, 230 insertions(+), 140 deletions(-) diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx index 43e273684..6fe342639 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useCallback, useMemo, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Badge, Button, @@ -20,8 +20,8 @@ import { TextInput, Title, Tooltip, -} from '@mantine/core'; -import { useDebouncedValue } from '@mantine/hooks'; +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; import { IconAlertCircle, IconDownload, @@ -29,9 +29,9 @@ import { IconSortAscending, IconSortDescending, IconX, -} from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; -import { useTranslation } from 'react-i18next'; +} from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; import { STATUS_COLORS, STATUS_LABELS, @@ -46,9 +46,15 @@ import { type LicenseApplication, type LicenseStatus, type QueueFilter, -} from '@ema-platform/api'; -import { AdvancedTable, EmptyState, ErrorState, AmharicDatePicker, type AdvancedColumn } from '@ema-platform/ui'; -import { computeSla } from '../sla'; +} from "@ema-platform/api"; +import { + AdvancedTable, + EmptyState, + ErrorState, + AmharicDatePicker, + type AdvancedColumn, +} from "@ema-platform/ui"; +import { computeSla } from "../sla"; import { DEFAULT_VIEW, SAVED_VIEWS, @@ -57,30 +63,30 @@ import { searchParamsFromFilter, writeLastView, type SavedViewId, -} from '../queue-views'; -import { exportApplicationsCsv } from '../export'; -import { setDensity } from '../../../store/preferences.slice'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard'; +} from "../queue-views"; +import { exportApplicationsCsv } from "../export"; +import { setDensity } from "../../../store/preferences.slice"; +import { useAppDispatch, useAppSelector } from "../../../store/hooks"; +import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../useQueueKeyboard"; const PAGE_SIZE = 10; const SEARCH_DEBOUNCE_MS = 300; const ALL_STATUSES: LicenseStatus[] = [ - 'SUBMITTED', - 'UNDER_REVIEW', - 'UNDER_EVALUATION', - 'RESUBMIT_REQUIRED', - 'INSPECTION_PENDING', - 'INSPECTION_COMPLETED', - 'ON_HOLD', - 'APPROVED', - 'PAYMENT_PENDING', - 'PAID', - 'PAYMENT_CONFIRMED', - 'CERTIFICATE_ISSUED', - 'COMPLETED', - 'REJECTED', + "SUBMITTED", + "UNDER_REVIEW", + "UNDER_EVALUATION", + "RESUBMIT_REQUIRED", + "INSPECTION_PENDING", + "INSPECTION_COMPLETED", + "ON_HOLD", + "APPROVED", + "PAYMENT_PENDING", + "PAID", + "PAYMENT_CONFIRMED", + "CERTIFICATE_ISSUED", + "COMPLETED", + "REJECTED", ]; /** @@ -100,11 +106,11 @@ export function LicenseQueuePage() { const density = useAppSelector((state) => state.preferences.density); const [view, setView] = useState( - () => (searchParams.get('view') as SavedViewId) || readLastView(), + () => (searchParams.get("view") as SavedViewId) || readLastView(), ); - const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1); + const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1); const [selected, setSelected] = useState([]); - const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? ''); + const [searchInput, setSearchInput] = useState(searchParams.get("q") ?? ""); const [cursor, setCursor] = useState(0); const [helpOpen, setHelpOpen] = useState(false); const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS); @@ -133,8 +139,8 @@ export function LicenseQueuePage() { // No explicit sort in the URL or view → newest submissions first, so // the queue opens showing what most needs attention rather than // whatever order the backend happens to return. - sortBy: urlFilter.sortBy ?? 'submittedAt', - sortDir: urlFilter.sortDir ?? 'DESC', + sortBy: urlFilter.sortBy ?? "submittedAt", + sortDir: urlFilter.sortDir ?? "DESC", take: PAGE_SIZE, skip: (page - 1) * PAGE_SIZE, }), @@ -143,14 +149,25 @@ export function LicenseQueuePage() { // One query per source; the two inactive ones are skipped, so switching // views costs a single request rather than keeping three in flight. - const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' }); - const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' }); - const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' }); + const queueQuery = useGetQueueQuery(filter, { + skip: activeView.source !== "queue", + }); + const mineQuery = useGetAssignedToMeQuery(filter, { + skip: activeView.source !== "mine", + }); + const allQuery = useGetAllApplicationsQuery(filter, { + skip: activeView.source !== "all", + }); const active = - activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery; + activeView.source === "queue" + ? queueQuery + : activeView.source === "mine" + ? mineQuery + : allQuery; const [claim, { isLoading: claiming }] = useClaimApplicationMutation(); - const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery(); + const [runExport, { isFetching: exporting }] = + useLazyExportApplicationsQuery(); /** * Exports every row the filter matches, not just the page on screen. @@ -159,24 +176,28 @@ export function LicenseQueuePage() { */ async function handleExport() { try { - const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap(); + const result = await runExport({ + ...filter, + take: undefined, + skip: undefined, + }).unwrap(); exportApplicationsCsv(result.items, i18n.language); if (result.truncated) { notifications.show({ - color: 'yellow', - title: t('queue.exportTruncated', 'Export truncated'), - message: t('queue.exportTruncatedBody', { + color: "yellow", + title: t("queue.exportTruncated", "Export truncated"), + message: t("queue.exportTruncatedBody", { exported: result.items.length, total: result.total, defaultValue: - 'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.', + "Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.", }), }); } } catch (err) { notifications.show({ - color: 'red', - title: t('queue.exportFailed', 'Export failed'), + color: "red", + title: t("queue.exportFailed", "Export failed"), message: extractErrorMessage(err), }); } @@ -208,9 +229,11 @@ export function LicenseQueuePage() { updateUrl(next, view, 1); }; - const toggleSort = (field: NonNullable) => { + const toggleSort = (field: NonNullable) => { const dir = - urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC'; + urlFilter.sortBy === field && urlFilter.sortDir !== "DESC" + ? "DESC" + : "ASC"; setFacet({ sortBy: field, sortDir: dir }); }; @@ -218,20 +241,23 @@ export function LicenseQueuePage() { try { await claim(id).unwrap(); notifications.show({ - color: 'teal', - title: t('queue.claimed', 'Claimed'), - message: t('queue.claimedBody', 'The application is now assigned to you.'), + color: "teal", + title: t("queue.claimed", "Claimed"), + message: t( + "queue.claimedBody", + "The application is now assigned to you.", + ), }); - changeView('mine'); + changeView("mine"); } catch (err) { // A 409 means another officer got there first — refresh so the queue // stops showing work that is no longer available. notifications.show({ - color: 'red', - title: t('queue.claimFailed', 'Could not claim'), + color: "red", + title: t("queue.claimFailed", "Could not claim"), message: extractErrorMessage( err, - t('queue.claimRace', 'Another officer already claimed it.'), + t("queue.claimRace", "Another officer already claimed it."), ), }); active.refetch(); @@ -242,19 +268,22 @@ export function LicenseQueuePage() { const results = await Promise.allSettled( selected.map((id) => claim(id).unwrap()), ); - const claimed = results.filter((r) => r.status === 'fulfilled').length; + const claimed = results.filter((r) => r.status === "fulfilled").length; const lost = results.length - claimed; notifications.show({ - color: lost ? 'yellow' : 'teal', - title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }), + color: lost ? "yellow" : "teal", + title: t("queue.bulkClaimed", { + count: claimed, + defaultValue: "{{count}} claimed", + }), // Partial success is the normal case in a shared queue, so it is // reported rather than swallowed or treated as total failure. message: lost - ? t('queue.bulkClaimPartial', { + ? t("queue.bulkClaimPartial", { count: lost, - defaultValue: '{{count}} were already taken by another officer.', + defaultValue: "{{count}} were already taken by another officer.", }) - : '', + : "", }); setSelected([]); active.refetch(); @@ -263,13 +292,15 @@ export function LicenseQueuePage() { const cursorRow = items[cursor]; useQueueKeyboard({ enabled: !helpOpen, - onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))), + onNext: () => + setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))), onPrevious: () => setCursor((c) => Math.max(c - 1, 0)), onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`), onClaim: () => { // Only unclaimed rows can be claimed; pressing c elsewhere is a no-op // rather than an error the officer has to read. - if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id); + if (cursorRow && cursorRow.assignedOfficerId === null) + handleClaim(cursorRow.id); }, onEscape: () => setSelected([]), onHelp: () => setHelpOpen(true), @@ -277,18 +308,30 @@ export function LicenseQueuePage() { const allSelected = items.length > 0 && selected.length === items.length; const sortIcon = - urlFilter.sortDir === 'DESC' ? : ; + urlFilter.sortDir === "DESC" ? ( + + ) : ( + + ); const hasFacets = Boolean( urlFilter.status?.length || - urlFilter.licenseTypeId || - urlFilter.assignee || - urlFilter.submittedFrom || - debouncedSearch, + urlFilter.licenseTypeId || + urlFilter.assignee || + urlFilter.submittedFrom || + debouncedSearch, ); - const sortableHeader = (label: string, field: NonNullable) => ( - toggleSort(field)}> + const sortableHeader = ( + label: string, + field: NonNullable, + ) => ( + toggleSort(field)} + > {label} {urlFilter.sortBy === field && sortIcon} @@ -299,18 +342,20 @@ export function LicenseQueuePage() { { header: ( 0 && !allSelected} - onChange={() => setSelected(allSelected ? [] : items.map((a) => a.id))} + onChange={() => + setSelected(allSelected ? [] : items.map((a) => a.id)) + } /> ), size: 40, cell: ({ row }) => ( { @@ -325,8 +370,8 @@ export function LicenseQueuePage() { ), }, { - header: sortableHeader(t('queue.number', 'App #'), 'applicationNumber'), - label: t('queue.number', 'App #'), + header: sortableHeader(t("queue.number", "App #"), "applicationNumber"), + label: t("queue.number", "App #"), cell: ({ row }) => ( {row.original.applicationNumber} @@ -334,25 +379,29 @@ export function LicenseQueuePage() { ), }, { - header: sortableHeader(t('queue.company', 'Company'), 'companyName'), - label: t('queue.company', 'Company'), - cell: ({ row }) => {row.original.companyName ?? '—'}, + header: sortableHeader(t("queue.company", "Company"), "companyName"), + label: t("queue.company", "Company"), + cell: ({ row }) => ( + {row.original.companyName ?? "—"} + ), }, { - header: t('queue.tin', 'TIN'), + header: t("queue.tin", "TIN"), cell: ({ row }) => ( - {row.original.tinNumber ?? '—'} + {row.original.tinNumber ?? "—"} ), }, { - header: t('queue.typeCol', 'Type'), - cell: ({ row }) => {row.original.licenseType?.name?.en ?? '—'}, + header: t("queue.typeCol", "Type"), + cell: ({ row }) => ( + {row.original.licenseType?.name?.en ?? "—"} + ), }, { - header: sortableHeader(t('queue.statusCol', 'Status'), 'status'), - label: t('queue.statusCol', 'Status'), + header: sortableHeader(t("queue.statusCol", "Status"), "status"), + label: t("queue.statusCol", "Status"), cell: ({ row }) => ( {STATUS_LABELS[row.original.status]} @@ -360,18 +409,23 @@ export function LicenseQueuePage() { ), }, { - header: sortableHeader(t('queue.submitted', 'Submitted'), 'submittedAt'), - label: t('queue.submitted', 'Submitted'), + header: sortableHeader( + t("queue.submitted", "Submitted"), + "submittedAt", + ), + label: t("queue.submitted", "Submitted"), cell: ({ row }) => ( {row.original.submittedAt - ? new Date(row.original.submittedAt).toLocaleDateString(i18n.language) - : '—'} + ? new Date(row.original.submittedAt).toLocaleDateString( + i18n.language, + ) + : "—"} ), }, { - header: t('queue.sla', 'Age / SLA'), + header: t("queue.sla", "Age / SLA"), cell: ({ row }) => { const sla = computeSla(row.original); return ( @@ -385,14 +439,19 @@ export function LicenseQueuePage() { }, }, { - header: '', - label: t('queue.actionsColumn', 'Actions'), - align: 'right', + header: "", + label: t("queue.actionsColumn", "Actions"), + align: "right", size: 140, cell: ({ row }) => - row.original.assignedOfficerId === null && row.original.status === 'SUBMITTED' ? ( - ) : ( ), }, ], - [t, i18n.language, urlFilter.sortBy, sortIcon, selected, allSelected, items, claiming], + [ + t, + i18n.language, + urlFilter.sortBy, + sortIcon, + selected, + allSelected, + items, + claiming, + ], ); return ( - +
- {t('queue.title', 'Licence applications')} + {t("queue.title", "Licence applications")} {typeCode && ( {t(`nav.type${typeCode}`, { defaultValue: typeCode })} @@ -423,10 +491,15 @@ export function LicenseQueuePage() { dispatch(setDensity(v as 'comfortable' | 'compact'))} + onChange={(v) => + dispatch(setDensity(v as "comfortable" | "compact")) + } data={[ - { label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' }, - { label: t('queue.compact', 'Compact'), value: 'compact' }, + { + label: t("queue.comfortable", "Comfortable"), + value: "comfortable", + }, + { label: t("queue.compact", "Compact"), value: "compact" }, ]} /> {/* Saved views, counted. */} - changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm"> + changeView((v as SavedViewId) ?? DEFAULT_VIEW)} + mb="sm" + > {SAVED_VIEWS.map((savedView) => ( } value={searchInput} onChange={(e) => setSearchInput(e.currentTarget.value)} w={240} /> ({ value: s, label: STATUS_LABELS[s] }))} + label={t("queue.status", "Status")} + placeholder={t("queue.anyStatus", "Any")} + data={ALL_STATUSES.map((s) => ({ + value: s, + label: STATUS_LABELS[s], + }))} value={urlFilter.status ?? []} onChange={(v) => setFacet({ status: v as LicenseStatus[] })} clearable @@ -484,8 +564,8 @@ export function LicenseQueuePage() { /> {!typeCode && (