feat(applications): add discard functionality for draft applications and update UI

This commit is contained in:
Nati
2026-08-31 07:11:05 +00:00
parent 9c81731759
commit 16ac650f5f
7 changed files with 150 additions and 21 deletions

View File

@@ -28,19 +28,21 @@ import {
IconEye,
IconInfoCircle,
IconShieldCheck,
IconTrash,
} from '@tabler/icons-react';
import { authStorage, useCurrentProfile } from '@ema-platform/auth';
import {
extractErrorMessage,
useApiQuery,
useBypassPaymentMutation,
useDiscardApplicationMutation,
useGetPaymentCapabilitiesQuery,
} from '@ema-platform/api';
import {
useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api';
import { PdfPreviewModal } from '@ema-platform/ui';
import { ConfirmModal, PdfPreviewModal } from '@ema-platform/ui';
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
// ---------------------------------------------------------------------------
@@ -59,6 +61,8 @@ interface CertificatesOverview {
id: string;
applicationId: string;
type: string;
/** Which license type this is a draft of — gates the Apply buttons. */
licenseTypeKey: string | null;
submitted: string;
status: string;
/** The fee owed at the current status, or null when nothing is due. */
@@ -153,6 +157,8 @@ export function CertificatesPage() {
// never rendered. Same shortcut My Applications offers.
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation();
const [discardTarget, setDiscardTarget] = useState<{ id: string; applicationId: string } | null>(null);
const { data, refetch } = useApiQuery<CertificatesOverview>({
url: '/certificates/my',
@@ -175,6 +181,22 @@ export function CertificatesPage() {
notifications.show({ color: 'red', title: 'Bypass failed', message: extractErrorMessage(err) });
}
};
const handleDiscard = async () => {
if (!discardTarget) return;
try {
await discardApplication(discardTarget.applicationId).unwrap();
notifications.show({
color: 'teal',
title: 'Draft discarded',
message: `${discardTarget.id} has been deleted.`,
});
setDiscardTarget(null);
refetch();
} catch (err) {
notifications.show({ color: 'red', title: 'Could not discard', message: extractErrorMessage(err) });
}
};
const certificates = data?.certificates ?? [];
const applications = data?.applications ?? [];
@@ -184,6 +206,12 @@ export function CertificatesPage() {
// The three queries below only build the human-readable reason list for
// the tooltip/banner — the gate itself is the one boolean.
const { profile, eligibleForCoc: canApply } = useCurrentProfile();
// One open draft per certificate type: the API resumes the existing draft
// rather than stacking a second, so the button says so instead of looking
// like it did nothing.
const draftTypeKeys = new Set(
applications.filter((a) => a.status === 'DRAFT').map((a) => a.licenseTypeKey),
);
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
@@ -251,23 +279,25 @@ export function CertificatesPage() {
disabled, so the reason still shows on hover. */}
<span>
<Group gap="xs">
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
disabled={!canApply}
>
Apply for CoC
</Button>
<Button
variant="light"
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
disabled={!canApply}
>
Apply for CoP
</Button>
{([
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
]).map(({ key, label, variant }) => {
const hasDraft = draftTypeKeys.has(key);
return (
<Button
key={key}
variant={variant}
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate(`/licensing/${key}/apply`)}
disabled={!canApply || hasDraft}
title={hasDraft ? `You already have a ${label} draft — continue it below.` : undefined}
>
{`Apply for ${label}`}
</Button>
);
})}
</Group>
</span>
</Tooltip>
@@ -372,8 +402,21 @@ export function CertificatesPage() {
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/applications/${app.applicationId}`)}
>
Details
{app.status === 'DRAFT' ? 'Continue' : 'Details'}
</Text>
{/* Drafts only: once submitted the filing is a record,
and withdrawing it is the officer's call. */}
{app.status === 'DRAFT' && (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => setDiscardTarget({ id: app.id, applicationId: app.applicationId })}
>
Discard
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
@@ -419,6 +462,16 @@ export function CertificatesPage() {
)}
</Paper>
<ConfirmModal
opened={!!discardTarget}
onClose={() => setDiscardTarget(null)}
onConfirm={handleDiscard}
loading={discarding}
title="Discard draft"
message={`Delete draft ${discardTarget?.id ?? ''}? Anything filled in so far is lost.`}
confirmLabel="Discard"
/>
<PdfPreviewModal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}

View File

@@ -1,5 +1,5 @@
import { Button, Group } from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import { IconDownload, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { LicenseApplication } from '@ema-platform/api';
@@ -32,6 +32,7 @@ export function applicationActionsColumn(
onPay: (app: LicenseApplication) => void;
onRetakeExam: (app: LicenseApplication) => void;
onOpen: (app: LicenseApplication) => void;
onDiscard: (app: LicenseApplication) => void;
},
): AdvancedColumn<LicenseApplication> {
return {
@@ -126,6 +127,19 @@ export function applicationActionsColumn(
: t('applications.actions.view')}
</Button>
)}
{/* Drafts only: after submission the filing is a record an officer
may already be reading, so it is withdrawn, not deleted. */}
{app.status === 'DRAFT' && (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => deps.onDiscard(app)}
>
{t('applications.actions.discard')}
</Button>
)}
</Group>
);
},

View File

@@ -29,7 +29,7 @@ import {
IconSearch,
IconX,
} from '@tabler/icons-react';
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, AmharicDatePicker, ConfirmModal, EmptyState, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard';
@@ -42,6 +42,7 @@ import {
applicantOrCompanyName,
extractErrorMessage,
useBypassPaymentMutation,
useDiscardApplicationMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
@@ -102,6 +103,8 @@ export function MyApplicationsPage() {
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation();
const [discardTarget, setDiscardTarget] = useState<{ id: string; label: string } | null>(null);
const { renewLicense, isRenewing } = useRenewLicense();
const { reissueLicense, isReissuing } = useReissueLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
@@ -135,6 +138,26 @@ export function MyApplicationsPage() {
}
}
/** Throws away an unfinished draft, after the applicant confirms. */
async function handleDiscard() {
if (!discardTarget) return;
try {
await discardApplication(discardTarget.id).unwrap();
notifications.show({
color: 'teal',
title: t('applications.actions.discarded'),
message: discardTarget.label,
});
setDiscardTarget(null);
} catch (err) {
notifications.show({
color: 'red',
title: t('applications.actions.discardFailed'),
message: extractErrorMessage(err),
});
}
}
/**
* Re-opens the examination fee for a failed candidate.
*
@@ -290,6 +313,8 @@ export function MyApplicationsPage() {
onRetakeExam: (app) => retakeExamFee(app.id),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
onDiscard: (app) =>
setDiscardTarget({ id: app.id, label: app.applicationNumber }),
}),
];
@@ -511,6 +536,19 @@ export function MyApplicationsPage() {
{tab === 'apply' && <LicenseCatalogue />}
</Stack>
<ConfirmModal
opened={!!discardTarget}
onClose={() => setDiscardTarget(null)}
onConfirm={handleDiscard}
loading={discarding}
title={t('applications.actions.discard')}
message={t('applications.actions.discardConfirm', {
number: discardTarget?.label ?? '',
})}
confirmLabel={t('applications.actions.discard')}
cancelLabel={t('common.cancel')}
/>
</Container>
);
}

View File

@@ -229,6 +229,10 @@ export const am: Translations = {
view: 'ይመልከቱ',
bypass: 'ክፍያ ዝለል',
renew: 'አድስ',
discard: 'አጥፋ',
discardConfirm: 'ረቂቅ {{number}} ይጥፋ? እስካሁን የተሞላው ሁሉ ይጠፋል።',
discarded: 'ረቂቁ ጠፍቷል',
discardFailed: 'ረቂቁን ማጥፋት አልተቻለም',
},
notice: {
paymentReceived:

View File

@@ -229,6 +229,11 @@ export const en = {
view: 'View',
bypass: 'Bypass payment',
renew: 'Renew',
discard: 'Discard',
discardConfirm:
'Delete draft {{number}}? Anything filled in so far is lost.',
discarded: 'Draft discarded',
discardFailed: 'Could not discard draft',
},
notice: {
paymentReceived:

View File

@@ -172,6 +172,15 @@ const handlers: MockHandler[] = [
return detail ? clone(detail) : undefined;
},
},
{
method: 'DELETE',
pattern: /^\/license-applications\/([\w-]+)$/,
respond: (_req, match) => {
delete mockApplications[match[1]];
delete mockApplicationDetails[match[1]];
return { deleted: true };
},
},
{
method: 'PATCH',
pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/,

View File

@@ -343,6 +343,11 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
}),
discardApplication: builder.mutation<{ deleted: boolean }, string>({
query: (id) => ({ url: `/license-applications/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
}),
getMyApplications: builder.query<Paginated<LicenseApplication>, void>({
query: () => ({ url: '/license-applications/mine' }),
providesTags: () => [listTag('LicenseApplication')],
@@ -1216,6 +1221,7 @@ export const {
useUpdateLicenseValidityMutation,
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
useDiscardApplicationMutation,
useGetMyApplicationsQuery,
useGetApplicationQuery,
useInitiatePaymentMutation,