This commit is contained in:
Estifo77
2026-08-05 14:17:00 +03:00
parent 5752362174
commit ed782237ca

View File

@@ -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>