mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
fixes
This commit is contained in:
@@ -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<SavedViewId>(
|
||||
() => (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<string[]>([]);
|
||||
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<QueueFilter['sortBy']>) => {
|
||||
const toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
|
||||
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' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
|
||||
urlFilter.sortDir === "DESC" ? (
|
||||
<IconSortDescending size={13} />
|
||||
) : (
|
||||
<IconSortAscending size={13} />
|
||||
);
|
||||
|
||||
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<QueueFilter['sortBy']>) => (
|
||||
<Group gap={4} wrap="nowrap" style={{ cursor: 'pointer' }} onClick={() => toggleSort(field)}>
|
||||
const sortableHeader = (
|
||||
label: string,
|
||||
field: NonNullable<QueueFilter["sortBy"]>,
|
||||
) => (
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => toggleSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{urlFilter.sortBy === field && sortIcon}
|
||||
</Group>
|
||||
@@ -299,18 +342,20 @@ export function LicenseQueuePage() {
|
||||
{
|
||||
header: (
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectAll', 'Select all')}
|
||||
aria-label={t("queue.selectAll", "Select all")}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.length > 0 && !allSelected}
|
||||
onChange={() => setSelected(allSelected ? [] : items.map((a) => a.id))}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? [] : items.map((a) => a.id))
|
||||
}
|
||||
/>
|
||||
),
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectRow', {
|
||||
aria-label={t("queue.selectRow", {
|
||||
number: row.original.applicationNumber,
|
||||
defaultValue: 'Select {{number}}',
|
||||
defaultValue: "Select {{number}}",
|
||||
})}
|
||||
checked={selected.includes(row.original.id)}
|
||||
onChange={(e) => {
|
||||
@@ -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 }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.applicationNumber}
|
||||
@@ -334,25 +379,29 @@ export function LicenseQueuePage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
header: sortableHeader(t('queue.company', 'Company'), 'companyName'),
|
||||
label: t('queue.company', 'Company'),
|
||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||
header: sortableHeader(t("queue.company", "Company"), "companyName"),
|
||||
label: t("queue.company", "Company"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.companyName ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('queue.tin', 'TIN'),
|
||||
header: t("queue.tin", "TIN"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.tinNumber ?? '—'}
|
||||
{row.original.tinNumber ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('queue.typeCol', 'Type'),
|
||||
cell: ({ row }) => <Text size="sm">{row.original.licenseType?.name?.en ?? '—'}</Text>,
|
||||
header: t("queue.typeCol", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.licenseType?.name?.en ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
{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 }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.submittedAt
|
||||
? new Date(row.original.submittedAt).toLocaleDateString(i18n.language)
|
||||
: '—'}
|
||||
? new Date(row.original.submittedAt).toLocaleDateString(
|
||||
i18n.language,
|
||||
)
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
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' ? (
|
||||
<Button size="xs" loading={claiming} onClick={() => handleClaim(row.original.id)}>
|
||||
{t('queue.claim', 'Claim')}
|
||||
row.original.assignedOfficerId === null &&
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<Button
|
||||
size="xs"
|
||||
loading={claiming}
|
||||
onClick={() => handleClaim(row.original.id)}
|
||||
>
|
||||
{t("queue.claim", "Claim")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -400,19 +459,28 @@ export function LicenseQueuePage() {
|
||||
variant="light"
|
||||
onClick={() => navigate(`/licence-review/${row.original.id}`)}
|
||||
>
|
||||
{t('queue.review', 'Review')}
|
||||
{t("queue.review", "Review")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, i18n.language, urlFilter.sortBy, sortIcon, selected, allSelected, items, claiming],
|
||||
[
|
||||
t,
|
||||
i18n.language,
|
||||
urlFilter.sortBy,
|
||||
sortIcon,
|
||||
selected,
|
||||
allSelected,
|
||||
items,
|
||||
claiming,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
||||
<Title order={3}>{t("queue.title", "Licence applications")}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
@@ -423,10 +491,15 @@ export function LicenseQueuePage() {
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={density}
|
||||
onChange={(v) => 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" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
@@ -436,13 +509,17 @@ export function LicenseQueuePage() {
|
||||
loading={exporting}
|
||||
disabled={total === 0}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
|
||||
<Tabs
|
||||
value={view}
|
||||
onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)}
|
||||
mb="sm"
|
||||
>
|
||||
<Tabs.List>
|
||||
{SAVED_VIEWS.map((savedView) => (
|
||||
<Tabs.Tab
|
||||
@@ -466,17 +543,20 @@ export function LicenseQueuePage() {
|
||||
<Paper withBorder p="sm" mb="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={t('queue.search', 'Search')}
|
||||
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
|
||||
label={t("queue.search", "Search")}
|
||||
placeholder={t("queue.searchPlaceholder", "Company, TIN or number")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
||||
w={240}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('queue.status', 'Status')}
|
||||
placeholder={t('queue.anyStatus', 'Any')}
|
||||
data={ALL_STATUSES.map((s) => ({ 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 && (
|
||||
<Select
|
||||
label={t('queue.type', 'Licence type')}
|
||||
placeholder={t('queue.anyType', 'Any')}
|
||||
label={t("queue.type", "Licence type")}
|
||||
placeholder={t("queue.anyType", "Any")}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name.en ?? type.key,
|
||||
@@ -497,29 +577,29 @@ export function LicenseQueuePage() {
|
||||
/>
|
||||
)}
|
||||
<AmharicDatePicker
|
||||
label={t('queue.submittedFrom', 'Submitted from')}
|
||||
value={urlFilter.submittedFrom ?? ''}
|
||||
label={t("queue.submittedFrom", "Submitted from")}
|
||||
value={urlFilter.submittedFrom ?? ""}
|
||||
onChange={(v) => setFacet({ submittedFrom: v || undefined })}
|
||||
dateFormat="date"
|
||||
w={220}
|
||||
w={170}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t('queue.submittedTo', 'Submitted to')}
|
||||
value={urlFilter.submittedTo ?? ''}
|
||||
label={t("queue.submittedTo", "Submitted to")}
|
||||
value={urlFilter.submittedTo ?? ""}
|
||||
onChange={(v) => setFacet({ submittedTo: v || undefined })}
|
||||
dateFormat="date"
|
||||
w={220}
|
||||
w={170}
|
||||
/>
|
||||
{hasFacets && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearchInput("");
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}}
|
||||
>
|
||||
{t('queue.clearFilters', 'Clear')}
|
||||
{t("queue.clearFilters", "Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -536,7 +616,7 @@ export function LicenseQueuePage() {
|
||||
</Stack>
|
||||
) : active.isError ? (
|
||||
<ErrorState
|
||||
title={t('queue.errorTitle', 'Could not load the queue')}
|
||||
title={t("queue.errorTitle", "Could not load the queue")}
|
||||
description={extractErrorMessage(active.error)}
|
||||
onRetry={() => active.refetch()}
|
||||
icon={IconAlertCircle}
|
||||
@@ -545,19 +625,29 @@ export function LicenseQueuePage() {
|
||||
<EmptyState
|
||||
title={
|
||||
hasFacets
|
||||
? t('queue.emptyFiltered', 'No applications match these filters')
|
||||
: t('queue.empty', 'Nothing waiting here')
|
||||
? t(
|
||||
"queue.emptyFiltered",
|
||||
"No applications match these filters",
|
||||
)
|
||||
: t("queue.empty", "Nothing waiting here")
|
||||
}
|
||||
description={
|
||||
hasFacets
|
||||
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
|
||||
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
|
||||
? t(
|
||||
"queue.emptyFilteredBody",
|
||||
"Try widening or clearing the filters.",
|
||||
)
|
||||
: t(
|
||||
"queue.emptyBody",
|
||||
"New applications will appear here as they are submitted.",
|
||||
)
|
||||
}
|
||||
action={
|
||||
hasFacets
|
||||
? {
|
||||
label: t('queue.clearFilters', 'Clear'),
|
||||
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
label: t("queue.clearFilters", "Clear"),
|
||||
onClick: () =>
|
||||
setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -566,18 +656,18 @@ export function LicenseQueuePage() {
|
||||
<>
|
||||
<Group justify="flex-end" p="sm" pb={0}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('queue.showing', {
|
||||
{t("queue.showing", {
|
||||
from: (page - 1) * PAGE_SIZE + 1,
|
||||
to: Math.min(page * PAGE_SIZE, total),
|
||||
total,
|
||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
defaultValue: "Showing {{from}}–{{to}} of {{total}}",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
tableName={t('queue.title', 'Licence applications')}
|
||||
tableName={t("queue.title", "Licence applications")}
|
||||
itemCount={total}
|
||||
pageIndex={page - 1}
|
||||
onPageChange={(pageIndex) => {
|
||||
@@ -588,12 +678,12 @@ export function LicenseQueuePage() {
|
||||
pageSize={PAGE_SIZE}
|
||||
refresh={() => active.refetch()}
|
||||
isLoading={active.isFetching}
|
||||
verticalSpacing={density === 'compact' ? 4 : 'sm'}
|
||||
verticalSpacing={density === "compact" ? 4 : "sm"}
|
||||
rowStyle={(_row, index) =>
|
||||
// Keyboard cursor. A left border rather than a background keeps
|
||||
// it distinguishable from row selection and from hover.
|
||||
index === cursor
|
||||
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
|
||||
? { boxShadow: "inset 3px 0 0 var(--mantine-color-blue-6)" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -604,7 +694,7 @@ export function LicenseQueuePage() {
|
||||
<Modal
|
||||
opened={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
title={t('shortcuts.title', 'Keyboard shortcuts')}
|
||||
title={t("shortcuts.title", "Keyboard shortcuts")}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
@@ -624,18 +714,18 @@ export function LicenseQueuePage() {
|
||||
withBorder
|
||||
shadow="md"
|
||||
p="sm"
|
||||
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
|
||||
style={{ position: "sticky", bottom: 16, zIndex: 50 }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('queue.selectedCount', {
|
||||
{t("queue.selectedCount", {
|
||||
count: selected.length,
|
||||
defaultValue: '{{count}} selected',
|
||||
defaultValue: "{{count}} selected",
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" onClick={() => setSelected([])}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -647,12 +737,12 @@ export function LicenseQueuePage() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t('queue.bulkClaim', {
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: 'Claim {{count}}',
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user