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:
fitse-yotor
2026-08-28 15:49:05 +03:00
parent 12e7927d01
commit 687e3df3c2
27 changed files with 1233 additions and 30 deletions

View File

@@ -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>
);
},
};
}

View File

@@ -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>
),
},
];
}

View File

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

View File

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