Files
emaui/apps/portal/src/app/features/licensing/components/LicenseCard.tsx

187 lines
5.9 KiB
TypeScript

import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Divider,
Group,
Text,
Tooltip,
} from '@mantine/core';
import { IconDownload, IconRefresh } from '@tabler/icons-react';
import {
extractErrorMessage,
useLocalized,
useCreateApplicationMutation,
type IssuedLicense,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { useTranslation } from 'react-i18next';
import {
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
RequirePermission,
} from '@ema-platform/auth';
/**
* Renewal reuses the ordinary application wizard — a renewal is an
* application of kind RENEWAL, asking for that licence type's renewal
* document set. `previousLicenseId` is what ties it to the certificate being
* replaced, and what the API requires.
*
* Shared by the dashboard and My Applications so both offer the same renew
* action instead of drifting.
*/
export function useRenewLicense() {
const navigate = useNavigate();
const { t } = useTranslation();
const [createApplication, { isLoading: isRenewing }] =
useCreateApplicationMutation();
async function renewLicense(license: IssuedLicense) {
const typeKey = license.licenseType?.key;
if (!typeKey) return;
try {
const application = await createApplication({
licenseType: typeKey,
kind: 'RENEWAL',
previousLicenseId: license.id,
}).unwrap();
navigate(`/licensing/${typeKey}/applications/${application.id}`);
} catch (err) {
notify.error(extractErrorMessage(err), t('licensing.card.renewFailed'));
}
}
return { renewLicense, isRenewing };
}
function daysUntil(date: string): number {
const ms = new Date(date).getTime() - Date.now();
return Math.ceil(ms / 86_400_000);
}
export function LicenseCard({
license,
isDownloading,
isRenewing,
onDownload,
onRenew,
}: {
license: IssuedLicense;
isDownloading: boolean;
isRenewing: boolean;
onDownload: () => void;
onRenew: () => void;
}) {
// The API computes both in the authority's timezone; the local fallbacks are
// only for a cached response from before those fields existed.
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
const expired = license.status === 'EXPIRED' || days < 0;
// Suspended, cancelled and superseded are none of them "valid until" their
// expiry date — the card used to say exactly that, because only EXPIRED was
// treated as not-current. A suspended licence read as a live one with a grey
// badge.
const current = license.status === 'ACTIVE' && !expired;
const renewable = license.renewable ?? false;
const showDate = useDateDisplayer();
const localized = useLocalized();
const { t } = useTranslation();
return (
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{localized(license.licenseType?.name) || t('licensing.card.fallbackName')}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{license.certificateNumber}
</Text>
</Box>
<Badge
size="sm"
variant="light"
color={expired ? 'red' : current ? 'teal' : 'gray'}
>
{/* The raw enum was rendered here, so an Amharic page showed
"SUSPENDED" among otherwise translated text. */}
{expired
? t('licensing.card.expired')
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Badge>
</Group>
<Divider my="sm" />
<Group justify="space-between" align="center">
<Box>
<Text size="sm" fw={500}>
{expired
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
: current
? t('licensing.card.validUntil', { date: showDate(license.expiryDate) })
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Text>
{/* Why it stopped being current. The API returns it; the card threw
it away, leaving the holder to guess. */}
{!current && license.statusReason && (
<Text size="xs" c="dimmed" mt={2}>
{t('licensing.card.statusReason', { reason: license.statusReason })}
</Text>
)}
</Box>
<RequirePermission
anyOf={[
PORTAL_PERMISSIONS.VIEW_OWN_CERTIFICATES,
PORTAL_PERMISSIONS.VIEW_COMPANY_LICENSES,
]}
hideOnly
>
<Tooltip label={t('licensing.card.downloadCertificate')}>
<ActionIcon
variant="light"
radius="md"
size="lg"
loading={isDownloading}
onClick={onDownload}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
</RequirePermission>
</Group>
{/* Renewal opens inside the licence type's window and stays open after
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
{renewable && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
<Button
fullWidth
mt="sm"
size="xs"
variant={expired ? 'filled' : 'light'}
color={expired ? 'orange' : undefined}
loading={isRenewing}
leftSection={<IconRefresh size={14} />}
onClick={onRenew}
>
{expired
? t('licensing.card.renewExpired')
: t('licensing.card.renewDays', { count: days })}
</Button>
</RequirePermission>
)}
</Card>
);
}
export default LicenseCard;