mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-02 22:03:46 +00:00
187 lines
5.9 KiB
TypeScript
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;
|