mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-03 14:23:41 +00:00
641 lines
25 KiB
TypeScript
641 lines
25 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Group,
|
||
MultiSelect,
|
||
NumberInput,
|
||
Paper,
|
||
Select,
|
||
Stack,
|
||
Switch,
|
||
Text,
|
||
TextInput,
|
||
Tooltip,
|
||
} from '@mantine/core';
|
||
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons-react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { LICENSE_PERMISSIONS, usePermissions } from '@ema-platform/auth';
|
||
import {
|
||
useUpdateLicenseBehaviorMutation,
|
||
type CertificateCategory,
|
||
type CompletionEffect,
|
||
type LicenseType,
|
||
type ServiceKind,
|
||
type WorkflowProfile,
|
||
} from '@ema-platform/api';
|
||
import { useRequirementActions } from '../hooks/useRequirementActions';
|
||
|
||
/** The shape the form edits — every field the behaviour endpoint accepts. */
|
||
interface Draft {
|
||
workflowProfile: WorkflowProfile;
|
||
serviceKind: ServiceKind;
|
||
completionEffect: CompletionEffect | null;
|
||
certificateCategory: CertificateCategory | null;
|
||
requiresExamination: boolean;
|
||
inspectionRequired: boolean;
|
||
issuesCertificate: boolean;
|
||
renewalEnabled: boolean;
|
||
requiresSeafarerRegistration: boolean;
|
||
requiresValidMedical: boolean;
|
||
minSeaTimeDays: number | null;
|
||
capitalThreshold: number | null;
|
||
validityMonths: number;
|
||
validityDays: number | null;
|
||
renewalWindowDays: number;
|
||
expiryReminderDays: number[];
|
||
requiresOperatorMode: boolean;
|
||
allowMultipleOpenDrafts: boolean;
|
||
requiresIssuanceScheduling: boolean;
|
||
uniqueFormKeyPath: string | null;
|
||
slaHours: number | null;
|
||
}
|
||
|
||
/** Offsets EMA reminds on. Fixed rather than free-form — these are policy, not arithmetic. */
|
||
const REMINDER_OFFSETS = ['90', '60', '30', '14', '7'];
|
||
|
||
function toDraft(licenseType: LicenseType): Draft {
|
||
return {
|
||
workflowProfile: licenseType.workflowProfile ?? 'STANDARD',
|
||
serviceKind: licenseType.serviceKind ?? 'LICENSE',
|
||
completionEffect: licenseType.completionEffect ?? null,
|
||
certificateCategory: licenseType.certificateCategory ?? null,
|
||
requiresExamination: licenseType.requiresExamination ?? false,
|
||
inspectionRequired: licenseType.inspectionRequired ?? true,
|
||
issuesCertificate: licenseType.issuesCertificate ?? true,
|
||
renewalEnabled: licenseType.renewalEnabled ?? true,
|
||
requiresSeafarerRegistration:
|
||
licenseType.requiresSeafarerRegistration ?? false,
|
||
requiresValidMedical: licenseType.requiresValidMedical ?? false,
|
||
minSeaTimeDays: licenseType.minSeaTimeDays ?? null,
|
||
capitalThreshold:
|
||
licenseType.capitalThreshold == null
|
||
? null
|
||
: Number(licenseType.capitalThreshold),
|
||
// Zero is a real stored state for `validityMonths` (a type that issues
|
||
// nothing with an expiry) and is kept. Zero in the other two is not a
|
||
// policy anyone set — it is an unfilled column — and seeding a box with a
|
||
// value below its own floor only produces a save the server rejects.
|
||
validityMonths: licenseType.validityMonths ?? 12,
|
||
validityDays: licenseType.validityDays || null,
|
||
renewalWindowDays: licenseType.renewalWindowDays || 60,
|
||
expiryReminderDays: licenseType.expiryReminderDays ?? [60, 30, 7],
|
||
requiresOperatorMode: licenseType.requiresOperatorMode ?? true,
|
||
allowMultipleOpenDrafts: licenseType.allowMultipleOpenDrafts ?? false,
|
||
requiresIssuanceScheduling: licenseType.requiresIssuanceScheduling ?? false,
|
||
uniqueFormKeyPath: licenseType.uniqueFormKeyPath ?? null,
|
||
slaHours: licenseType.slaHours ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* How one licence type behaves: the course it runs, who may apply, when it
|
||
* renews and what rules the applicant meets.
|
||
*
|
||
* These were seed-only until now — changing an SLA target or a renewal window
|
||
* meant editing a file and redeploying. They are read live off the licence
|
||
* type rather than snapshotted onto applications, so a change here applies to
|
||
* files already in the queue as well as new ones. For the three settings that
|
||
* decide an application's course the server refuses the change outright while
|
||
* anything is still awaiting a decision, rather than stranding it.
|
||
*/
|
||
export function BehaviorTab({ licenseType }: { licenseType: LicenseType }) {
|
||
const { t } = useTranslation();
|
||
const run = useRequirementActions();
|
||
const { can } = usePermissions();
|
||
const canEdit = can([LICENSE_PERMISSIONS.UPDATE_LICENSE_TYPE]);
|
||
|
||
const [save, { isLoading: saving }] = useUpdateLicenseBehaviorMutation();
|
||
const [draft, setDraft] = useState<Draft>(() => toDraft(licenseType));
|
||
const [dirty, setDirty] = useState(false);
|
||
|
||
// Keyed on the id alone, like FormSchemaTab: this tab's own save invalidates
|
||
// the licence-type list, and the refetch that follows must not overwrite an
|
||
// edit the administrator is still working on.
|
||
useEffect(() => {
|
||
setDraft(toDraft(licenseType));
|
||
setDirty(false);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [licenseType.id]);
|
||
|
||
function patch(values: Partial<Draft>) {
|
||
setDraft((current) => ({ ...current, ...values }));
|
||
setDirty(true);
|
||
}
|
||
|
||
function set<K extends keyof Draft>(key: K, value: Draft[K]) {
|
||
patch({ [key]: value } as Partial<Draft>);
|
||
}
|
||
|
||
// Nothing this type issues carries an expiry date — a transfer, or any
|
||
// one-off record. Renewal, its window and its reminders are all measured
|
||
// against an expiry that never arrives, so none of them are asked for.
|
||
const expires = draft.validityDays !== null || draft.validityMonths > 0;
|
||
|
||
// The server's floor for a stated term is 6 months, so no-expiry is a state
|
||
// this form can hold and edit around but cannot switch a type into. Offered
|
||
// only where it is already what the type is, rather than as an option whose
|
||
// save would be refused.
|
||
const noExpiryAvailable =
|
||
(licenseType.validityMonths ?? 12) === 0 && !licenseType.validityDays;
|
||
|
||
async function onSave() {
|
||
// Only the settings this form actually asked for. The endpoint patches, so
|
||
// an omitted field keeps its stored value — and the fields hidden above are
|
||
// hidden precisely because the type has no such policy, which the server
|
||
// stores as a zero its own validators then refuse (`validityMonths` has a
|
||
// floor of 6, `renewalWindowDays` of 1). Echoing those back is what made
|
||
// saving an ownership transfer fail outright.
|
||
const {
|
||
validityMonths,
|
||
validityDays,
|
||
renewalWindowDays,
|
||
expiryReminderDays,
|
||
...rest
|
||
} = draft;
|
||
|
||
const ok = await run(
|
||
() =>
|
||
save({
|
||
id: licenseType.id,
|
||
...rest,
|
||
...(expires ? { validityMonths, validityDays } : {}),
|
||
...(expires && draft.renewalEnabled
|
||
? { renewalWindowDays, expiryReminderDays }
|
||
: {}),
|
||
}).unwrap(),
|
||
t('certReq.behavior.saved', 'Configuration saved.'),
|
||
);
|
||
if (ok) setDirty(false);
|
||
}
|
||
|
||
return (
|
||
<Stack gap="md">
|
||
<Section title={t('certReq.behavior.workflow', 'Workflow')}>
|
||
<Alert
|
||
variant="light"
|
||
color="yellow"
|
||
icon={<IconAlertTriangle size={16} />}
|
||
>
|
||
<Text size="sm">
|
||
{t(
|
||
'certReq.behavior.workflowWarning',
|
||
'These three settings decide the course an application runs. They cannot be changed while applications of this type are still awaiting a decision — saving will be refused until those are decided.',
|
||
)}
|
||
</Text>
|
||
</Alert>
|
||
|
||
<Select
|
||
label={t('certReq.behavior.workflowProfile', 'Workflow profile')}
|
||
description={t(
|
||
'certReq.behavior.workflowProfileHint',
|
||
'REGISTRATION drops the evaluation and inspection stages.',
|
||
)}
|
||
data={[
|
||
{ value: 'STANDARD', label: t('certReq.behavior.standard', 'Standard licence course') },
|
||
{ value: 'REGISTRATION', label: t('certReq.behavior.registration', 'Registration (review only)') },
|
||
]}
|
||
value={draft.workflowProfile}
|
||
onChange={(v) => v && set('workflowProfile', v as WorkflowProfile)}
|
||
allowDeselect={false}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Select
|
||
label={t('certReq.behavior.completionEffect', 'Completion effect')}
|
||
description={t(
|
||
'certReq.behavior.completionEffectHint',
|
||
'Platform action taken when an application of this type completes.',
|
||
)}
|
||
data={[
|
||
{ value: 'REGISTER_SEAFARER', label: t('certReq.behavior.registerSeafarer', 'Register the seafarer') },
|
||
{ value: 'REGISTER_VESSEL', label: t('certReq.behavior.registerVessel', 'Register the vessel') },
|
||
{ value: 'OPEN_SEAFARER_DOCUMENTS', label: t('certReq.behavior.openDocuments', 'Open seafarer documents') },
|
||
]}
|
||
value={draft.completionEffect}
|
||
onChange={(v) => set('completionEffect', (v as CompletionEffect) ?? null)}
|
||
placeholder={t('certReq.behavior.noEffect', 'No side effect')}
|
||
clearable
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Switch
|
||
checked={draft.requiresExamination}
|
||
onChange={(e) => set('requiresExamination', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.requiresExamination', 'Requires an examination')}
|
||
description={t(
|
||
'certReq.behavior.requiresExaminationHint',
|
||
'Routes the application through eligibility, then exam, then certificate. Set the three stage fees on the payment configuration screen.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Switch
|
||
checked={draft.issuesCertificate}
|
||
onChange={(e) => set('issuesCertificate', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.issuesCertificate', 'Issues a certificate')}
|
||
description={t(
|
||
'certReq.behavior.issuesCertificateHint',
|
||
'Turn off for a type that ends with an EMA decision and never reaches a payment stage. Turning it back on requires a new-application fee to be set, or approved applicants would be asked for a fee that does not exist.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Select
|
||
label={t('certReq.behavior.serviceKind', 'Service kind')}
|
||
description={t(
|
||
'certReq.behavior.serviceKindHint',
|
||
'Catalogue classification only — no workflow depends on it.',
|
||
)}
|
||
data={[
|
||
{ value: 'LICENSE', label: t('certReq.behavior.license', 'Licence') },
|
||
{ value: 'REGISTRATION', label: t('certReq.behavior.registrationKind', 'Registration') },
|
||
]}
|
||
value={draft.serviceKind}
|
||
onChange={(v) => v && set('serviceKind', v as ServiceKind)}
|
||
allowDeselect={false}
|
||
disabled={!canEdit}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title={t('certReq.behavior.eligibility', 'Eligibility gates')}>
|
||
<Text size="xs" c="dimmed">
|
||
{t(
|
||
'certReq.behavior.eligibilityHint',
|
||
'Checked when an applicant submits, and the capital requirement again when an officer approves. Tightening a gate can stop someone mid-way from submitting a draft they have already started.',
|
||
)}
|
||
</Text>
|
||
|
||
<NullableNumber
|
||
label={t('certReq.behavior.capitalThreshold', 'Minimum paid-up capital')}
|
||
switchLabel={t('certReq.behavior.capitalOn', 'Require a minimum paid-up capital')}
|
||
description={t(
|
||
'certReq.behavior.capitalHint',
|
||
'An officer cannot approve until they have verified capital at or above this. Applies to applications already in the queue, not just new ones.',
|
||
)}
|
||
value={draft.capitalThreshold}
|
||
onChange={(v) => set('capitalThreshold', v)}
|
||
disabled={!canEdit}
|
||
min={0}
|
||
defaultValue={1_000_000}
|
||
thousandSeparator
|
||
/>
|
||
|
||
<Switch
|
||
checked={draft.requiresSeafarerRegistration}
|
||
onChange={(e) => set('requiresSeafarerRegistration', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.requiresSeafarer', 'Requires an active seafarer registration')}
|
||
description={t(
|
||
'certReq.behavior.requiresSeafarerHint',
|
||
'Also marks this type as a seafarer certificate, which is what makes it appear in the seafarer portal.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Switch
|
||
checked={draft.requiresValidMedical}
|
||
onChange={(e) => set('requiresValidMedical', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.requiresMedical', 'Requires a current medical certificate')}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<NullableNumber
|
||
label={t('certReq.behavior.minSeaTime', 'Minimum sea time (days)')}
|
||
switchLabel={t('certReq.behavior.minSeaTimeOn', 'Require verified sea time')}
|
||
value={draft.minSeaTimeDays}
|
||
onChange={(v) => set('minSeaTimeDays', v)}
|
||
disabled={!canEdit}
|
||
min={0}
|
||
/>
|
||
|
||
<Select
|
||
label={t('certReq.behavior.certificateCategory', 'Certificate category')}
|
||
description={t(
|
||
'certReq.behavior.certificateCategoryHint',
|
||
'What kind of document this issues. Endorsements are grouped separately in the seafarer portal.',
|
||
)}
|
||
data={['COC', 'COP', 'ENDORSEMENT', 'GOC', 'NATIONAL'].map((v) => ({
|
||
value: v,
|
||
label: v,
|
||
}))}
|
||
value={draft.certificateCategory}
|
||
onChange={(v) => set('certificateCategory', (v as CertificateCategory) ?? null)}
|
||
placeholder={t('certReq.behavior.notACertificate', 'Not a certificate')}
|
||
clearable
|
||
disabled={!canEdit}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title={t('certReq.behavior.renewal', 'Validity and renewal')}>
|
||
{/* Amount + unit rather than a years box that silently divides by 12:
|
||
the seed says `validityMonths: 12` and this now says "12 Months",
|
||
so the two read the same. Months advance the calendar (issued on
|
||
the 31st, expires on the 31st); days are for terms shorter than a
|
||
month can express. The third unit is no term at all, which the
|
||
server stores as `validityMonths: 0`. */}
|
||
<Group align="flex-end" gap="sm" wrap="nowrap">
|
||
{expires && (
|
||
<NumberInput
|
||
label={t('certReq.behavior.validity', 'Valid for')}
|
||
description={t(
|
||
'certReq.behavior.validityHint',
|
||
'Applied when a licence is issued. Licences already issued keep the expiry date they were given.',
|
||
)}
|
||
value={draft.validityDays ?? draft.validityMonths}
|
||
onChange={(v) => {
|
||
const next = typeof v === 'number' ? v : 0;
|
||
if (!next) return;
|
||
if (draft.validityDays !== null) set('validityDays', next);
|
||
else set('validityMonths', next);
|
||
}}
|
||
// Matches the server's ranges, so the box cannot offer a value the
|
||
// save would reject: 1–3650 days, or 6–240 months.
|
||
min={draft.validityDays !== null ? 1 : 6}
|
||
max={draft.validityDays !== null ? 3650 : 240}
|
||
allowNegative={false}
|
||
disabled={!canEdit}
|
||
flex={1}
|
||
/>
|
||
)}
|
||
<Select
|
||
label={
|
||
expires
|
||
? undefined
|
||
: t('certReq.behavior.validityUnit', 'Validity unit')
|
||
}
|
||
aria-label={t('certReq.behavior.validityUnit', 'Validity unit')}
|
||
data={[
|
||
{ value: 'MONTHS', label: t('certReq.behavior.unitMonths', 'Months') },
|
||
{ value: 'DAYS', label: t('certReq.behavior.unitDays', 'Days') },
|
||
...(noExpiryAvailable
|
||
? [
|
||
{
|
||
value: 'NONE',
|
||
label: t('certReq.behavior.unitNone', 'Does not expire'),
|
||
},
|
||
]
|
||
: []),
|
||
]}
|
||
value={
|
||
draft.validityDays !== null
|
||
? 'DAYS'
|
||
: draft.validityMonths > 0
|
||
? 'MONTHS'
|
||
: 'NONE'
|
||
}
|
||
onChange={(unit) => {
|
||
// Switching unit is a change of policy, not a conversion: 12
|
||
// calendar months is not 365 days, so carry no arithmetic across
|
||
// and let the administrator state the new term outright.
|
||
if (unit === 'DAYS') set('validityDays', draft.validityDays ?? 90);
|
||
else if (unit === 'MONTHS')
|
||
patch({
|
||
validityDays: null,
|
||
validityMonths:
|
||
draft.validityMonths >= 6 ? draft.validityMonths : 12,
|
||
});
|
||
// No expiry means no renewal policy: clear it here rather than
|
||
// save renewal settings that could never fire.
|
||
else
|
||
patch({
|
||
validityDays: null,
|
||
validityMonths: 0,
|
||
renewalEnabled: false,
|
||
});
|
||
}}
|
||
allowDeselect={false}
|
||
disabled={!canEdit}
|
||
w={expires ? 130 : 220}
|
||
/>
|
||
</Group>
|
||
|
||
{!expires && (
|
||
<Text size="xs" c="dimmed">
|
||
{t(
|
||
'certReq.behavior.noExpiryHint',
|
||
'What this type issues never expires — an ownership transfer, or any one-off record. There is no renewal policy to set.',
|
||
)}
|
||
</Text>
|
||
)}
|
||
|
||
{expires && (
|
||
<>
|
||
<Switch
|
||
checked={draft.renewalEnabled}
|
||
onChange={(e) => set('renewalEnabled', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.renewalEnabled', 'Holders may renew this licence')}
|
||
description={t(
|
||
'certReq.behavior.renewalEnabledHint',
|
||
'Off for one-off issues — a registration that does not lapse, or a waiver written for a single shipment.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
{/* The window and the reminders are both measured against an expiry
|
||
a non-renewing licence never reaches, so they are hidden rather
|
||
than shown as settings that quietly do nothing. */}
|
||
{draft.renewalEnabled && (
|
||
<>
|
||
<NumberInput
|
||
label={t('certReq.behavior.renewalWindow', 'Renewal opens (days before expiry)')}
|
||
value={draft.renewalWindowDays}
|
||
onChange={(v) =>
|
||
set('renewalWindowDays', typeof v === 'number' ? v : draft.renewalWindowDays)
|
||
}
|
||
min={1}
|
||
max={365}
|
||
allowNegative={false}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<MultiSelect
|
||
label={t('certReq.behavior.reminders', 'Expiry reminders (days before)')}
|
||
description={t(
|
||
'certReq.behavior.remindersHint',
|
||
'The holder is reminded at each of these offsets.',
|
||
)}
|
||
data={REMINDER_OFFSETS}
|
||
value={draft.expiryReminderDays.map(String)}
|
||
onChange={(values) =>
|
||
set(
|
||
'expiryReminderDays',
|
||
values.map(Number).sort((a, b) => b - a),
|
||
)
|
||
}
|
||
disabled={!canEdit}
|
||
clearable
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title={t('certReq.behavior.applicantRules', 'Applicant rules')}>
|
||
<Switch
|
||
checked={draft.requiresOperatorMode}
|
||
onChange={(e) => set('requiresOperatorMode', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.requiresOperatorMode', 'Applicant must declare this operating mode')}
|
||
description={t(
|
||
'certReq.behavior.requiresOperatorModeHint',
|
||
'Turn off for person-centric registrations any signed-in applicant may start.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Switch
|
||
checked={draft.allowMultipleOpenDrafts}
|
||
onChange={(e) => set('allowMultipleOpenDrafts', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.allowMultipleDrafts', 'Allow several open drafts at once')}
|
||
description={t(
|
||
'certReq.behavior.allowMultipleDraftsHint',
|
||
'On for per-asset registrations — registering a second vessel must not resume the first one’s draft.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Switch
|
||
checked={draft.inspectionRequired}
|
||
onChange={(e) => set('inspectionRequired', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.inspectionRequired', 'Requires a physical inspection')}
|
||
description={t(
|
||
'certReq.behavior.inspectionRequiredHint',
|
||
'An officer cannot approve until an inspection has been recorded and passed. Safe to change with applications in progress — an officer can still assign an inspector to a file already under evaluation.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<Switch
|
||
checked={draft.requiresIssuanceScheduling}
|
||
onChange={(e) => set('requiresIssuanceScheduling', e.currentTarget.checked)}
|
||
label={t('certReq.behavior.requiresScheduling', 'Schedule a pickup date before issuing')}
|
||
description={t(
|
||
'certReq.behavior.requiresSchedulingHint',
|
||
'For documents printed once and handed over in person.',
|
||
)}
|
||
disabled={!canEdit}
|
||
/>
|
||
|
||
<NullableNumber
|
||
label={t('certReq.behavior.slaHours', 'Decision target (hours)')}
|
||
switchLabel={t('certReq.behavior.slaOn', 'Track against an SLA')}
|
||
value={draft.slaHours}
|
||
onChange={(v) => set('slaHours', v)}
|
||
disabled={!canEdit}
|
||
min={1}
|
||
description={t(
|
||
'certReq.behavior.slaHint',
|
||
'Drives the Age/SLA column and the Overdue view. Applies to applications already submitted as well as new ones.',
|
||
)}
|
||
/>
|
||
|
||
<TextInput
|
||
label={t('certReq.behavior.uniqueFormKey', 'One application per answer at')}
|
||
description={t(
|
||
'certReq.behavior.uniqueFormKeyHint',
|
||
'Dotted form path, e.g. shipment.billOfLading. The field must exist in this type’s form, or no application can be submitted.',
|
||
)}
|
||
value={draft.uniqueFormKeyPath ?? ''}
|
||
onChange={(e) =>
|
||
set('uniqueFormKeyPath', e.currentTarget.value.trim() || null)
|
||
}
|
||
maxLength={128}
|
||
placeholder={t('certReq.behavior.noUniqueRule', 'No uniqueness rule')}
|
||
disabled={!canEdit}
|
||
/>
|
||
</Section>
|
||
|
||
<Group justify="flex-end">
|
||
<Tooltip
|
||
label={t('certReq.behavior.noPermission', 'You do not have permission to change licence configuration.')}
|
||
disabled={canEdit}
|
||
>
|
||
<Button
|
||
leftSection={<IconDeviceFloppy size={16} />}
|
||
onClick={onSave}
|
||
loading={saving}
|
||
disabled={!canEdit || !dirty}
|
||
>
|
||
{t('certReq.behavior.save', 'Save configuration')}
|
||
</Button>
|
||
</Tooltip>
|
||
</Group>
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
function Section({
|
||
title,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<Paper withBorder radius="md" p="md">
|
||
<Stack gap="sm">
|
||
<Text fw={600} size="sm">
|
||
{title}
|
||
</Text>
|
||
{children}
|
||
</Stack>
|
||
</Paper>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A number that may be switched off entirely.
|
||
*
|
||
* Null is a real configuration state here — "not tracked against an SLA", "no
|
||
* sea-time floor" — and is not the same as an empty box, so the choice gets its
|
||
* own switch rather than being inferred from a blank field.
|
||
*/
|
||
function NullableNumber({
|
||
label,
|
||
switchLabel,
|
||
description,
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
min,
|
||
defaultValue,
|
||
thousandSeparator,
|
||
}: {
|
||
label: string;
|
||
switchLabel: string;
|
||
description?: string;
|
||
value: number | null;
|
||
onChange: (value: number | null) => void;
|
||
disabled: boolean;
|
||
min: number;
|
||
/** Seeded when the switch is turned on. Defaults to the minimum. */
|
||
defaultValue?: number;
|
||
thousandSeparator?: boolean;
|
||
}) {
|
||
return (
|
||
<Stack gap="xs">
|
||
<Switch
|
||
checked={value !== null}
|
||
onChange={(e) =>
|
||
onChange(e.currentTarget.checked ? (defaultValue ?? min ?? 1) : null)
|
||
}
|
||
label={switchLabel}
|
||
description={description}
|
||
disabled={disabled}
|
||
/>
|
||
{value !== null && (
|
||
<NumberInput
|
||
label={label}
|
||
value={value}
|
||
onChange={(v) => onChange(typeof v === 'number' ? v : value)}
|
||
min={min}
|
||
allowNegative={false}
|
||
thousandSeparator={thousandSeparator ? ',' : undefined}
|
||
decimalScale={thousandSeparator ? 2 : undefined}
|
||
disabled={disabled}
|
||
/>
|
||
)}
|
||
</Stack>
|
||
);
|
||
}
|