feat: add CountrySelect component and integrate country selection in endorsement, seafarer, and waiver pages

This commit is contained in:
estifanos
2026-07-30 12:11:53 +00:00
parent 06eb9eca4f
commit 0db451bdec
7 changed files with 125 additions and 24 deletions

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { CountrySelect, getCountryName } from '@ema-platform/ui';
import {
Alert,
Badge,
@@ -84,7 +85,7 @@ function ApplicationWizard({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const [cocNo, setCocNo] = useState('');
const [issuer, setIssuer] = useState('');
const [country, setCountry] = useState('');
const [country, setCountry] = useState<string | null>(null);
const [cocType, setCocType] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
@@ -129,7 +130,7 @@ function ApplicationWizard({ onDone }: { onDone: () => void }) {
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
<TextInput label="Issuing Country" placeholder="e.g. Philippines" value={country} onChange={(e) => setCountry(e.currentTarget.value)} required />
<CountrySelect label="Issuing Country" value={country} onChange={setCountry} required />
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} required />
@@ -231,7 +232,7 @@ function ApplicationWizard({ onDone }: { onDone: () => void }) {
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
{[
['CoC Number', cocNo],
['Country', country],
['Country', getCountryName(country)],
['Issuer', issuer],
['CoC Type', cocType],
['Issue Date', issueDate],

View File

@@ -45,7 +45,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { CountrySelect, notify, useErrorHandler } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
@@ -506,6 +506,8 @@ function AddMedicalModal({ opened, onClose }: { opened: boolean; onClose: () =>
}
function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const [flag, setFlag] = useState<string | null>(null);
return (
<Modal opened={opened} onClose={onClose} title="Add Sea Service Record" size="lg">
<Stack gap="sm">
@@ -515,7 +517,7 @@ function AddSeaServiceModal({ opened, onClose }: { opened: boolean; onClose: ()
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Rank" placeholder="e.g. Able Seaman" />
<TextInput label="Flag" placeholder="Country" />
<CountrySelect label="Flag" value={flag} onChange={setFlag} />
</SimpleGrid>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="From" type="date" />

View File

@@ -34,7 +34,7 @@ import {
IconShieldOff,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { CountrySelect, getCountryName, notify } from '@ema-platform/ui';
const STEPS = [
{ label: 'Waiver Type & Applicant' },
@@ -200,7 +200,7 @@ export function WaiverApplicationPage() {
const [invoiceNumber, setInvoiceNumber] = useState('');
const [invoiceDate, setInvoiceDate] = useState('');
const [invoiceAmount, setInvoiceAmount] = useState<string | number>('');
const [countryOfOrigin, setCountryOfOrigin] = useState('');
const [countryOfOrigin, setCountryOfOrigin] = useState<string | null>(null);
const [supplierName, setSupplierName] = useState('');
const [portOfLoading, setPortOfLoading] = useState('');
@@ -266,7 +266,7 @@ export function WaiverApplicationPage() {
businessLicenseNumber, applicantAddress, reasonNonEsl, bankName, bankBranch,
letterRecipient, letterLanguage,
productName, productDescription, quantity, unitOfMeasurement,
invoiceNumber, invoiceDate, invoiceAmount, countryOfOrigin, supplierName,
invoiceNumber, invoiceDate, invoiceAmount, countryOfOrigin: getCountryName(countryOfOrigin), supplierName,
portOfLoading, portOfDischarge, fromPortName, toPortName, vesselName, carrierName,
billOfLadingNumber, estimatedArrivalDate, actualArrivalDate,
},
@@ -366,7 +366,7 @@ export function WaiverApplicationPage() {
<TextInput label="Product Description" required value={productDescription} onChange={(e) => setProductDescription(e.currentTarget.value)} />
<NumberInput label="Quantity" required min={0} value={quantity} onChange={setQuantity} />
<TextInput label="Unit of Measurement" required value={unitOfMeasurement} onChange={(e) => setUnitOfMeasurement(e.currentTarget.value)} />
<TextInput label="Country of Origin" value={countryOfOrigin} onChange={(e) => setCountryOfOrigin(e.currentTarget.value)} />
<CountrySelect label="Country of Origin" value={countryOfOrigin} onChange={setCountryOfOrigin} />
<TextInput label="Supplier / Exporter Name" value={supplierName} onChange={(e) => setSupplierName(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Invoice Information" />

View File

@@ -1,4 +1,5 @@
export * from './lib/input/BilingualInput';
export * from './lib/input/CountrySelect';
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';

View File

@@ -0,0 +1,84 @@
import { Group, Select, Text, type ComboboxItem, type SelectProps } from '@mantine/core';
import { getData, getName, overwrite } from 'country-list';
import * as Flags from 'country-flag-icons/react/3x2';
// Friendlier labels for a few ISO-official names ("Korea, Republic of" → "South Korea").
// Full list still comes from country-list; this only renames.
overwrite([
{ code: 'BO', name: 'Bolivia' },
{ code: 'IR', name: 'Iran' },
{ code: 'KP', name: 'North Korea' },
{ code: 'KR', name: 'South Korea' },
{ code: 'RU', name: 'Russia' },
{ code: 'SY', name: 'Syria' },
{ code: 'TW', name: 'Taiwan' },
{ code: 'US', name: 'United States' },
{ code: 'VN', name: 'Vietnam' },
]);
// Built once at module load — static data, no hook memoization needed.
const COUNTRIES = getData()
.map(({ code, name }) => ({ value: code, label: name }))
.sort((a, b) => a.label.localeCompare(b.label));
/** English country name for an alpha-2 code; '' when unset/unknown. */
export function getCountryName(code: string | null | undefined): string {
return code ? (getName(code) ?? '') : '';
}
function CountryFlag({ code }: { code: string }) {
const Flag = Flags[code as keyof typeof Flags];
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
}
// Case-insensitive substring on name, plus ISO code prefix ("et" → Ethiopia).
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
const q = search.trim().toLowerCase();
if (!q) return options;
return (options as ComboboxItem[]).filter(
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().startsWith(q),
);
};
// Module scope → stable reference, no re-render churn.
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => (
<Group gap="xs" wrap="nowrap">
<CountryFlag code={option.value} />
<Text fz="sm">{option.label}</Text>
</Group>
);
export interface CountrySelectProps {
value: string | null;
onChange: (value: string | null) => void;
label?: string;
placeholder?: string;
description?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
disabled?: boolean;
}
export function CountrySelect({
value,
onChange,
placeholder = 'Select a country',
...rest
}: CountrySelectProps) {
return (
<Select
data={COUNTRIES}
value={value}
onChange={onChange}
placeholder={placeholder}
leftSection={value ? <CountryFlag code={value} /> : undefined}
renderOption={renderCountryOption}
filter={filterCountries}
nothingFoundMessage="No countries found"
searchable
clearable
maxDropdownHeight={320}
{...rest}
/>
);
}

40
package-lock.json generated
View File

@@ -22,6 +22,8 @@
"@tanstack/react-query": "^5.99.0",
"@tria-plc/iamui": "file:local-packages/tria-plc-iamui-0.1.1.tgz",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"country-list": "^2.4.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"i18next": "^25.6.0",
@@ -44,6 +46,7 @@
"@nx/react": "^22.5.4",
"@nx/vite": "^22.5.4",
"@nx/vitest": "^22.5.4",
"@types/country-list": "^2.1.4",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.0.0",
"@types/react": "^19.2.17",
@@ -3870,18 +3873,6 @@
}
}
},
"node_modules/@mui/x-date-pickers/node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@mui/x-date-pickers/node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
@@ -9022,6 +9013,13 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/country-list": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/country-list/-/country-list-2.1.4.tgz",
"integrity": "sha512-c4orjm0wT+BRt6Ynf2/QRRL+iMX4SdLT11jAjKqkJ7mQr/DLOmdz7qR8i04j9Y7wFSoP70cXJc18FN4YGNgGaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -11259,6 +11257,18 @@
}
}
},
"node_modules/country-flag-icons": {
"version": "1.6.20",
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.20.tgz",
"integrity": "sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==",
"license": "MIT"
},
"node_modules/country-list": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/country-list/-/country-list-2.4.1.tgz",
"integrity": "sha512-KhVV/UfUV3dSNpsWIqHTQxLpYDKPKz1UwkRjadt+YbX2PRhyCEihEoS5XgB7J7AMXpkicvl+tRHvkNI5wbji/g==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -11944,7 +11954,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.2"
@@ -13735,7 +13745,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -17921,7 +17931,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/sax": {

View File

@@ -27,6 +27,8 @@
"@tanstack/react-query": "^5.99.0",
"@tria-plc/iamui": "file:local-packages/tria-plc-iamui-0.1.1.tgz",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"country-list": "^2.4.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"i18next": "^25.6.0",
@@ -49,6 +51,7 @@
"@nx/react": "^22.5.4",
"@nx/vite": "^22.5.4",
"@nx/vitest": "^22.5.4",
"@types/country-list": "^2.1.4",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.0.0",
"@types/react": "^19.2.17",