mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-02 00:03:27 +00:00
feat(applications): add discard functionality for draft applications and update UI
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,6 +229,10 @@ export const am: Translations = {
|
||||
view: 'ይመልከቱ',
|
||||
bypass: 'ክፍያ ዝለል',
|
||||
renew: 'አድስ',
|
||||
discard: 'አጥፋ',
|
||||
discardConfirm: 'ረቂቅ {{number}} ይጥፋ? እስካሁን የተሞላው ሁሉ ይጠፋል።',
|
||||
discarded: 'ረቂቁ ጠፍቷል',
|
||||
discardFailed: 'ረቂቁን ማጥፋት አልተቻለም',
|
||||
},
|
||||
notice: {
|
||||
paymentReceived:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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-]+)$/,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user