mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-04 07:53:45 +00:00
feat: add pickup appointment scheduling and management features
- Introduced new pickup appointment functionalities in the licensing API, including scheduling, rescheduling, and managing pickup offices. - Added UI components for the pickup desk, allowing officers to check in, issue documents, and manage no-show appointments. - Implemented a new page for managing pickup offices with CRUD operations. - Enhanced internationalization support for new pickup-related terms and messages. - Updated licensing types to include new application kinds and issuance periods. - Created a read-only panel for displaying scheduled pickup appointments in the licensing component.
This commit is contained in:
@@ -1,14 +1,21 @@
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
|
||||
import { Badge, Checkbox, Group, Text, Tooltip } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
applicantOrCompanyName,
|
||||
localized,
|
||||
type ApplicationKind,
|
||||
type LicenseApplication,
|
||||
type QueueFilter,
|
||||
} from "@ema-platform/api";
|
||||
|
||||
const KIND_COLOR: Record<ApplicationKind, string> = {
|
||||
NEW: "blue",
|
||||
RENEWAL: "teal",
|
||||
REISSUE: "orange",
|
||||
};
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { dateDisplayer } from "@ema-platform/shared";
|
||||
import { computeSla } from "../../sla";
|
||||
@@ -112,9 +119,19 @@ export function licenseQueueColumns(
|
||||
{
|
||||
header: t("queue.typeCol", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{localized(row.original.licenseType?.name, locale) || "—"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">
|
||||
{localized(row.original.licenseType?.name, locale) || "—"}
|
||||
</Text>
|
||||
{row.original.kind !== "NEW" && (
|
||||
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
|
||||
{t(
|
||||
`queue.kindValues.${row.original.kind}`,
|
||||
row.original.kind === "RENEWAL" ? "Renewal" : "Replacement",
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
useGetQueueCountsQuery,
|
||||
useGetQueueQuery,
|
||||
useLazyExportApplicationsQuery,
|
||||
type ApplicationKind,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type LicenseType,
|
||||
@@ -432,6 +433,7 @@ export function LicenseQueuePage() {
|
||||
const hasFacets = Boolean(
|
||||
urlFilter.status?.length ||
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.kind ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
@@ -580,6 +582,19 @@ export function LicenseQueuePage() {
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label={t("queue.kind", "Application kind")}
|
||||
placeholder={t("queue.anyType", "Any")}
|
||||
data={[
|
||||
{ value: "NEW", label: t("queue.kindValues.NEW", "New") },
|
||||
{ value: "RENEWAL", label: t("queue.kindValues.RENEWAL", "Renewal") },
|
||||
{ value: "REISSUE", label: t("queue.kindValues.REISSUE", "Replacement") },
|
||||
]}
|
||||
value={urlFilter.kind ?? null}
|
||||
onChange={(v) => setFacet({ kind: (v as ApplicationKind) ?? undefined })}
|
||||
clearable
|
||||
w={180}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t("queue.submittedFrom", "Submitted from")}
|
||||
value={urlFilter.submittedFrom ?? ""}
|
||||
|
||||
@@ -261,6 +261,9 @@ export function LicenseReviewPage() {
|
||||
);
|
||||
const [issuanceOpen, setIssuanceOpen] = useState(false);
|
||||
const [issuanceDate, setIssuanceDate] = useState("");
|
||||
const [issuancePeriod, setIssuancePeriod] = useState<"MORNING" | "AFTERNOON">(
|
||||
"MORNING",
|
||||
);
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
|
||||
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
|
||||
@@ -837,6 +840,11 @@ export function LicenseReviewPage() {
|
||||
<Badge color={STATUS_COLORS[status]} variant="light">
|
||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||
</Badge>
|
||||
{data.issuedLicenseStatus === "SUPERSEDED" && (
|
||||
<Badge color="gray" variant="light">
|
||||
{t("review.certificateSuperseded", "Certificate superseded")}
|
||||
</Badge>
|
||||
)}
|
||||
{app.adjustmentRound > 0 && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t("review.round", {
|
||||
@@ -1458,9 +1466,17 @@ export function LicenseReviewPage() {
|
||||
value={issuanceDate}
|
||||
onChange={setIssuanceDate}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={issuancePeriod}
|
||||
onChange={(value) => setIssuancePeriod(value as "MORNING" | "AFTERNOON")}
|
||||
data={[
|
||||
{ value: "MORNING", label: t("review.morning", "Morning") },
|
||||
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
|
||||
]}
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Tooltip
|
||||
label={t("review.pickDate", "Pick a date and time first")}
|
||||
label={t("review.pickDate", "Pick a date first")}
|
||||
disabled={Boolean(issuanceDate)}
|
||||
>
|
||||
<span>
|
||||
@@ -1478,6 +1494,7 @@ export function LicenseReviewPage() {
|
||||
await scheduleIssuance({
|
||||
id,
|
||||
scheduledDate: issuanceDate,
|
||||
scheduledPeriod: issuancePeriod,
|
||||
}).unwrap();
|
||||
setIssuanceOpen(false);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconCheck, IconUserCheck, IconX } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { PickupAppointment } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
export function pickupDeskActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onCheckIn: (appointment: PickupAppointment) => void;
|
||||
onIssue: (appointment: PickupAppointment) => void;
|
||||
onNoShow: (appointment: PickupAppointment) => void;
|
||||
},
|
||||
loadingId: string | null,
|
||||
): AdvancedColumn<PickupAppointment> {
|
||||
return {
|
||||
header: '',
|
||||
size: 260,
|
||||
align: 'right',
|
||||
cell: ({ row }) => {
|
||||
const appointment = row.original;
|
||||
const loading = loadingId === appointment.id;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{appointment.status === 'SCHEDULED' && (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={loading}
|
||||
leftSection={<IconUserCheck size={14} />}
|
||||
onClick={() => handlers.onCheckIn(appointment)}
|
||||
>
|
||||
{t('pickupDesk.checkIn', 'Check in')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{(appointment.status === 'SCHEDULED' || appointment.status === 'CHECKED_IN') && (
|
||||
<>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.ISSUE_CERTIFICATE]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="teal"
|
||||
loading={loading}
|
||||
leftSection={<IconCheck size={14} />}
|
||||
onClick={() => handlers.onIssue(appointment)}
|
||||
>
|
||||
{t('pickupDesk.issue', 'Issue')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={loading}
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onNoShow(appointment)}
|
||||
>
|
||||
{t('pickupDesk.noShow', 'No-show')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { PickupAppointment, PickupOffice } from '@ema-platform/api';
|
||||
|
||||
const STATUS_COLOR: Record<PickupAppointment['status'], string> = {
|
||||
SCHEDULED: 'cyan',
|
||||
CHECKED_IN: 'yellow',
|
||||
ISSUED: 'green',
|
||||
NO_SHOW: 'red',
|
||||
RESCHEDULED: 'gray',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
export function pickupDeskColumns(
|
||||
t: TFunction,
|
||||
officesById: Map<string, PickupOffice>,
|
||||
): AdvancedColumn<PickupAppointment>[] {
|
||||
return [
|
||||
{
|
||||
header: t('pickupDesk.columns.time', 'Time'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} ff="monospace">
|
||||
{row.original.slotStartTime}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupDesk.columns.appointment', 'Appointment'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" ff="monospace">
|
||||
{row.original.appointmentNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupDesk.columns.office', 'Office'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{officesById.get(row.original.officeId)?.name ?? '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupDesk.columns.status', 'Status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{row.original.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Select, Stack, ThemeIcon } from '@mantine/core';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { AmharicDatePicker, AdvancedTable, PageHeader, notify, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useCheckInPickupMutation,
|
||||
useGetPickupOfficesQuery,
|
||||
useGetPickupWorklistQuery,
|
||||
useIssueCertificateMutation,
|
||||
useMarkPickupIssuedMutation,
|
||||
useMarkPickupNoShowMutation,
|
||||
type PickupAppointment,
|
||||
} from '@ema-platform/api';
|
||||
import { pickupDeskActionsColumn } from './actions';
|
||||
import { pickupDeskColumns } from './columns';
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pickup officer's worklist for one day (spec §43): who is booked, when,
|
||||
* and where they are in the visit. Check-in and no-show are pickup-desk
|
||||
* concerns; Issue calls the existing certificate-issuance endpoint and then
|
||||
* marks the appointment issued, so the two stay in the same state a
|
||||
* `SCHEDULED` application has always moved through.
|
||||
*/
|
||||
export function PickupDeskPage() {
|
||||
const { t } = useTranslation();
|
||||
const [date, setDate] = useState(todayIso());
|
||||
const [officeId, setOfficeId] = useState<string | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
const { data: offices } = useGetPickupOfficesQuery();
|
||||
const {
|
||||
data: appointments,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useGetPickupWorklistQuery({ date, officeId: officeId ?? undefined });
|
||||
|
||||
const [checkIn] = useCheckInPickupMutation();
|
||||
const [markIssued] = useMarkPickupIssuedMutation();
|
||||
const [markNoShow] = useMarkPickupNoShowMutation();
|
||||
const [issueCertificate] = useIssueCertificateMutation();
|
||||
|
||||
const officesById = useMemo(
|
||||
() => new Map((offices ?? []).map((o) => [o.id, o])),
|
||||
[offices],
|
||||
);
|
||||
const officeOptions = useMemo(
|
||||
() => (offices ?? []).map((o) => ({ value: o.id, label: o.name })),
|
||||
[offices],
|
||||
);
|
||||
|
||||
const rows = [...(appointments ?? [])].sort((a, b) =>
|
||||
a.slotStartTime.localeCompare(b.slotStartTime),
|
||||
);
|
||||
const page = paginate(rows);
|
||||
|
||||
async function withLoading(id: string, action: () => Promise<unknown>) {
|
||||
setLoadingId(id);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('pickupDesk.actionFailed', 'Action failed'));
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckIn(appointment: PickupAppointment) {
|
||||
await withLoading(appointment.id, () => checkIn(appointment.id).unwrap());
|
||||
}
|
||||
|
||||
async function handleIssue(appointment: PickupAppointment) {
|
||||
await withLoading(appointment.id, async () => {
|
||||
// Renders and stores the certificate — the same action a raw
|
||||
// schedule-only application reaches from the review page.
|
||||
await issueCertificate(appointment.applicationId).unwrap();
|
||||
await markIssued(appointment.id).unwrap();
|
||||
notify.success(t('pickupDesk.issued', 'Document issued'));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNoShow(appointment: PickupAppointment) {
|
||||
await withLoading(appointment.id, () => markNoShow(appointment.id).unwrap());
|
||||
}
|
||||
|
||||
const columns = [
|
||||
...pickupDeskColumns(t, officesById),
|
||||
pickupDeskActionsColumn(
|
||||
t,
|
||||
{ onCheckIn: handleCheckIn, onIssue: handleIssue, onNoShow: handleNoShow },
|
||||
loadingId,
|
||||
),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={t('pickupDesk.title', 'Pickup Desk')}
|
||||
subtitle={t(
|
||||
'pickupDesk.subtitle',
|
||||
"Today's and upcoming document pickup appointments.",
|
||||
)}
|
||||
noMargin
|
||||
action={
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCalendarEvent size={22} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Group gap="sm">
|
||||
<AmharicDatePicker
|
||||
label={t('pickupDesk.date', 'Date')}
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
/>
|
||||
<Select
|
||||
label={t('pickupDesk.office', 'Office')}
|
||||
placeholder={t('pickupDesk.allOffices', 'All offices')}
|
||||
data={officeOptions}
|
||||
value={officeId}
|
||||
onChange={setOfficeId}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
tableName="pickup-desk-appointments"
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('pickupDesk.empty', 'No appointments for this day.')}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default PickupDeskPage;
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from '@mantine/core';
|
||||
import { IconBuildingWarehouse, IconPlus } from '@tabler/icons-react';
|
||||
import { AdvancedTable, ModalFooter, PageHeader, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useCreatePickupOfficeMutation,
|
||||
useGetPickupOfficesQuery,
|
||||
useUpdatePickupOfficeMutation,
|
||||
type PickupOffice,
|
||||
} from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
|
||||
const WEEKDAYS = [
|
||||
{ value: '0', label: 'Sun' },
|
||||
{ value: '1', label: 'Mon' },
|
||||
{ value: '2', label: 'Tue' },
|
||||
{ value: '3', label: 'Wed' },
|
||||
{ value: '4', label: 'Thu' },
|
||||
{ value: '5', label: 'Fri' },
|
||||
{ value: '6', label: 'Sat' },
|
||||
];
|
||||
|
||||
type OfficeDraft = {
|
||||
name: string;
|
||||
address: string;
|
||||
workingDays: string[];
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
slotDurationMinutes: number;
|
||||
maxApplicantsPerSlot: number;
|
||||
rescheduleMinNoticeHours: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: OfficeDraft = {
|
||||
name: '',
|
||||
address: '',
|
||||
workingDays: ['1', '2', '3', '4', '5'],
|
||||
startTime: '08:30',
|
||||
endTime: '17:00',
|
||||
slotDurationMinutes: 30,
|
||||
maxApplicantsPerSlot: 10,
|
||||
rescheduleMinNoticeHours: 24,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
function toDraft(office: PickupOffice): OfficeDraft {
|
||||
return {
|
||||
name: office.name,
|
||||
address: office.address ?? '',
|
||||
workingDays: office.workingDays.map(String),
|
||||
startTime: office.startTime,
|
||||
endTime: office.endTime,
|
||||
slotDurationMinutes: office.slotDurationMinutes,
|
||||
maxApplicantsPerSlot: office.maxApplicantsPerSlot,
|
||||
rescheduleMinNoticeHours: office.rescheduleMinNoticeHours,
|
||||
isActive: office.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Office/location, working hours, slot capacity and reschedule cutoff — the
|
||||
* configuration `PickupService.availableSlots` computes real slots from
|
||||
* (spec §20). Holiday management lives here too, one office at a time,
|
||||
* rather than a separate page — a holiday has no meaning without an office.
|
||||
*/
|
||||
export function PickupOfficesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: offices, isFetching, refetch } = useGetPickupOfficesQuery();
|
||||
const [createOffice, { isLoading: creating }] = useCreatePickupOfficeMutation();
|
||||
const [updateOffice, { isLoading: updating }] = useUpdatePickupOfficeMutation();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
const [editing, setEditing] = useState<PickupOffice | null>(null);
|
||||
const [creatingNew, setCreatingNew] = useState(false);
|
||||
const [draft, setDraft] = useState<OfficeDraft>(EMPTY_DRAFT);
|
||||
|
||||
const page = paginate(offices ?? []);
|
||||
|
||||
function openEdit(office: PickupOffice) {
|
||||
setEditing(office);
|
||||
setDraft(toDraft(office));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setCreatingNew(true);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
}
|
||||
|
||||
function close() {
|
||||
setEditing(null);
|
||||
setCreatingNew(false);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const body = {
|
||||
name: draft.name,
|
||||
address: draft.address || undefined,
|
||||
workingDays: draft.workingDays.map(Number),
|
||||
startTime: draft.startTime,
|
||||
endTime: draft.endTime,
|
||||
slotDurationMinutes: draft.slotDurationMinutes,
|
||||
maxApplicantsPerSlot: draft.maxApplicantsPerSlot,
|
||||
rescheduleMinNoticeHours: draft.rescheduleMinNoticeHours,
|
||||
isActive: draft.isActive,
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await updateOffice({ id: editing.id, ...body }).unwrap();
|
||||
} else {
|
||||
await createOffice(body).unwrap();
|
||||
}
|
||||
notify.success(t('pickupOffices.saved', 'Office saved'));
|
||||
close();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), t('pickupOffices.saveFailed', 'Could not save'));
|
||||
}
|
||||
}
|
||||
|
||||
const columns: AdvancedColumn<PickupOffice>[] = [
|
||||
{
|
||||
header: t('pickupOffices.columns.name', 'Office'),
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.original.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.address ?? '—'}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupOffices.columns.hours', 'Working hours'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.startTime}–{row.original.endTime}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupOffices.columns.capacity', 'Capacity / slot'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.maxApplicantsPerSlot} · {row.original.slotDurationMinutes}min
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('pickupOffices.columns.status', 'Status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" color={row.original.isActive ? 'teal' : 'gray'} variant="light">
|
||||
{row.original.isActive
|
||||
? t('pickupOffices.active', 'Active')
|
||||
: t('pickupOffices.inactive', 'Inactive')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
|
||||
<Button size="xs" variant="light" onClick={() => openEdit(row.original)}>
|
||||
{t('pickupOffices.edit', 'Edit')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={t('pickupOffices.title', 'Pickup Offices')}
|
||||
subtitle={t(
|
||||
'pickupOffices.subtitle',
|
||||
'Where applicants collect printed documents, and how many can be booked into each slot.',
|
||||
)}
|
||||
noMargin
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconBuildingWarehouse size={22} />
|
||||
</ThemeIcon>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
|
||||
{t('pickupOffices.new', 'New office')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<AdvancedTable
|
||||
tableName="pickup-offices"
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(editing) || creatingNew}
|
||||
onClose={close}
|
||||
title={editing ? t('pickupOffices.editTitle', 'Edit office') : t('pickupOffices.new', 'New office')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.name', 'Name')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.currentTarget.value }))}
|
||||
withAsterisk
|
||||
/>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.address', 'Address')}
|
||||
value={draft.address}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, address: e.currentTarget.value }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('pickupOffices.form.workingDays', 'Working days')}
|
||||
data={WEEKDAYS}
|
||||
value={draft.workingDays}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, workingDays: v }))}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.startTime', 'Start time')}
|
||||
placeholder="08:30"
|
||||
value={draft.startTime}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, startTime: e.currentTarget.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('pickupOffices.form.endTime', 'End time')}
|
||||
placeholder="17:00"
|
||||
value={draft.endTime}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, endTime: e.currentTarget.value }))}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t('pickupOffices.form.slotDuration', 'Slot length (min)')}
|
||||
min={5}
|
||||
value={draft.slotDurationMinutes}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, slotDurationMinutes: Number(v) || d.slotDurationMinutes }))
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t('pickupOffices.form.capacity', 'Max per slot')}
|
||||
min={1}
|
||||
value={draft.maxApplicantsPerSlot}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({ ...d, maxApplicantsPerSlot: Number(v) || d.maxApplicantsPerSlot }))
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label={t('pickupOffices.form.rescheduleCutoff', 'Reschedule minimum notice (hours)')}
|
||||
min={0}
|
||||
value={draft.rescheduleMinNoticeHours}
|
||||
onChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
rescheduleMinNoticeHours: Number(v) || d.rescheduleMinNoticeHours,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
label={t('pickupOffices.form.active', 'Active')}
|
||||
checked={draft.isActive}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, isActive: e.currentTarget.checked }))}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={close}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={creating || updating} disabled={!draft.name.trim()} onClick={save}>
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default PickupOfficesPage;
|
||||
@@ -5,10 +5,13 @@ import { IconSearch } from '@tabler/icons-react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
useListSeafarerDocumentsQuery,
|
||||
type SeafarerDocumentKind,
|
||||
type SeafarerDocumentRequestKind,
|
||||
type SeafarerDocumentRow,
|
||||
type SeafarerDocumentStatus,
|
||||
} from '@ema-platform/api';
|
||||
@@ -22,6 +25,10 @@ const STATUS_FILTERS = (
|
||||
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
|
||||
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
|
||||
|
||||
const REQUEST_KIND_FILTERS = (
|
||||
['NEW', 'RENEWAL', 'REPLACEMENT'] as SeafarerDocumentRequestKind[]
|
||||
).map((value) => ({ value, label: SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[value] }));
|
||||
|
||||
/**
|
||||
* The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests
|
||||
* appear here once the seafarer registration that opened them is approved.
|
||||
@@ -30,6 +37,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
|
||||
const [requestKind, setRequestKind] = useState<SeafarerDocumentRequestKind | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -38,6 +46,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
|
||||
kind,
|
||||
status: status ?? undefined,
|
||||
requestKind: requestKind ?? undefined,
|
||||
search: debouncedSearch || undefined,
|
||||
take: pageSize,
|
||||
skip: page * pageSize,
|
||||
@@ -75,6 +84,15 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
accessorKey: 'requestKind',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[row.original.requestKind]}>
|
||||
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[row.original.requestKind]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Fee',
|
||||
accessorKey: 'feeAmount',
|
||||
@@ -148,6 +166,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All types"
|
||||
data={REQUEST_KIND_FILTERS}
|
||||
value={requestKind}
|
||||
onChange={(v) => {
|
||||
setRequestKind(v as SeafarerDocumentRequestKind | null);
|
||||
setPage(0);
|
||||
}}
|
||||
clearable
|
||||
w={160}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
itemCount={data?.total ?? 0}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Alert, Badge, Button, Center, Container, Group, Loader, Modal, Paper, St
|
||||
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
|
||||
import {
|
||||
SEAFARER_DOCUMENT_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
|
||||
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
|
||||
SEAFARER_DOCUMENT_STATUS_COLORS,
|
||||
SEAFARER_DOCUMENT_STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
@@ -118,6 +120,11 @@ export function SeafarerDocumentReviewPage() {
|
||||
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
|
||||
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
|
||||
</Badge>
|
||||
{document.requestKind !== 'NEW' && (
|
||||
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]}>
|
||||
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
|
||||
</Badge>
|
||||
)}
|
||||
{document.documentNumber && (
|
||||
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{document.documentNumber}
|
||||
|
||||
@@ -96,6 +96,8 @@ export const am: Translations = {
|
||||
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
|
||||
applications: "ማመልከቻዎች",
|
||||
paymentConfig: "የክፍያ ውቅረት",
|
||||
pickupDesk: "የመረከቢያ ዴስክ",
|
||||
pickupOffices: "የመረከቢያ ቢሮዎች",
|
||||
analytics: "ትንታኔ",
|
||||
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
|
||||
medicalVerification: "የሕክምና ማረጋገጫ",
|
||||
@@ -830,6 +832,12 @@ export const am: Translations = {
|
||||
type: "ዓይነት",
|
||||
anyType: "ማንኛውም",
|
||||
typeCol: "ዓይነት",
|
||||
kind: "የማመልከቻ ዓይነት",
|
||||
kindValues: {
|
||||
NEW: "አዲስ",
|
||||
RENEWAL: "እድሳት",
|
||||
REISSUE: "ምትክ",
|
||||
},
|
||||
statusCol: "ሁኔታ",
|
||||
statusValues: {
|
||||
DRAFT: "ረቂቅ",
|
||||
@@ -904,6 +912,7 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
review: {
|
||||
certificateSuperseded: "ሰርተፍኬቱ ተተክቷል",
|
||||
summary: "ማጠቃለያ",
|
||||
officer: "ሹም",
|
||||
supervisor: "የበላይ ኃላፊ",
|
||||
@@ -996,6 +1005,8 @@ export const am: Translations = {
|
||||
reject: "አትቀበል",
|
||||
scheduleExam: "የፈተና ቀጠሮ ስጥ",
|
||||
confirmPayment: "ክፍያ አረጋግጥ",
|
||||
scheduleIssuance: "የመረከቢያ ቀጠሮ ያዝ",
|
||||
issueCertificate: "ሰርተፍኬት ስጥ",
|
||||
print: "ሰነድ አትም",
|
||||
copyLink: "አገናኝ ቅዳ",
|
||||
downloadDocuments: "ሁሉንም ሰነዶች አውርድ",
|
||||
|
||||
@@ -95,6 +95,8 @@ export const en = {
|
||||
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
pickupDesk: 'Pickup Desk',
|
||||
pickupOffices: 'Pickup Offices',
|
||||
analytics: 'Analytics',
|
||||
seaServiceVerification: 'Sea Service Verification',
|
||||
medicalVerification: 'Medical Verification',
|
||||
@@ -836,6 +838,12 @@ export const en = {
|
||||
type: 'Type',
|
||||
anyType: 'Any',
|
||||
typeCol: 'Type',
|
||||
kind: 'Application kind',
|
||||
kindValues: {
|
||||
NEW: 'New',
|
||||
RENEWAL: 'Renewal',
|
||||
REISSUE: 'Replacement',
|
||||
},
|
||||
statusCol: 'Status',
|
||||
statusValues: {
|
||||
DRAFT: 'Draft',
|
||||
@@ -912,6 +920,7 @@ export const en = {
|
||||
},
|
||||
|
||||
review: {
|
||||
certificateSuperseded: 'Certificate superseded',
|
||||
summary: 'Summary',
|
||||
officer: 'Officer',
|
||||
supervisor: 'Supervisor',
|
||||
@@ -1004,6 +1013,8 @@ export const en = {
|
||||
reject: 'Reject',
|
||||
scheduleExam: 'Schedule exam',
|
||||
confirmPayment: 'Confirm payment',
|
||||
scheduleIssuance: 'Schedule pickup',
|
||||
issueCertificate: 'Issue certificate',
|
||||
print: 'Print dossier',
|
||||
copyLink: 'Copy link',
|
||||
downloadDocuments: 'Download all documents',
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
IconAnchor,
|
||||
IconArrowsExchange,
|
||||
IconBook2,
|
||||
IconBuildingWarehouse,
|
||||
IconCalendarEvent,
|
||||
IconChartBar,
|
||||
IconClipboardList,
|
||||
IconClipboardText,
|
||||
@@ -157,6 +159,18 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
icon: IconCreditCard,
|
||||
permissions: [P.VIEW_PAYMENTS],
|
||||
},
|
||||
{
|
||||
to: '/pickup-desk',
|
||||
label: 'nav.pickupDesk',
|
||||
icon: IconCalendarEvent,
|
||||
permissions: [P.MANAGE_PICKUP_DESK],
|
||||
},
|
||||
{
|
||||
to: '/pickup-offices',
|
||||
label: 'nav.pickupOffices',
|
||||
icon: IconBuildingWarehouse,
|
||||
permissions: [P.CONFIGURE_PICKUP_OFFICES],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
SeaServiceVerificationPage,
|
||||
} from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
import { PickupDeskPage } from '../features/pickup/pages/PickupDeskPage';
|
||||
import { PickupOfficesPage } from '../features/pickup/pages/PickupOfficesPage';
|
||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
|
||||
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
|
||||
@@ -95,6 +97,8 @@ const router = createBrowserRouter([
|
||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||
{ path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], <PickupDeskPage />) },
|
||||
{ path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], <PickupOfficesPage />) },
|
||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||
// Seafarer registration is not a licence: own queue, own review.
|
||||
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
||||
|
||||
Reference in New Issue
Block a user