mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: add CountrySelect component and integrate country selection in endorsement, seafarer, and waiver pages
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
84
libs/ui/src/lib/input/CountrySelect.tsx
Normal file
84
libs/ui/src/lib/input/CountrySelect.tsx
Normal 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
40
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user