diff --git a/backend/migrations/core/110_normalize_country_code_fl_to_li.js b/backend/migrations/core/110_normalize_country_code_fl_to_li.js new file mode 100644 index 00000000..26cba940 --- /dev/null +++ b/backend/migrations/core/110_normalize_country_code_fl_to_li.js @@ -0,0 +1,40 @@ +/** + * Migration: normalize the Liechtenstein country code from the + * colloquial vehicle-plate code `FL` to the ISO 3166-1 alpha-2 code + * `LI`. + * + * Background: the customer create/edit UI used to accept a free-text + * 2-char country code and the placeholder suggested `FL` for + * Liechtenstein. That code isn't ISO — the PDF renderer's locale-aware + * lookup (services/pdfService.js countryName) and the new country + * dropdown both key on ISO, so `FL` rows render as the bare code + * instead of "Liechtenstein". The dropdown now stores `LI`; this + * migration brings existing rows in line so they display correctly and + * match new records. + * + * Scope: customer_accounts.country_code and business_profile.country_code. + * Case-insensitive so a hand-entered `fl` is caught too. The free-text + * country_name override column is left untouched — it exists precisely + * for operators who want a custom display string. + * + * Idempotent: re-runs only touch rows still holding FL, so a second run + * is a no-op. + */ + +async function normalizeColumn(knex, table) { + if (!(await knex.schema.hasTable(table))) return; + if (!(await knex.schema.hasColumn(table, 'country_code'))) return; + await knex(table) + .whereRaw('UPPER(country_code) = ?', ['FL']) + .update({ country_code: 'LI' }); +} + +exports.up = async function(knex) { + await normalizeColumn(knex, 'customer_accounts'); + await normalizeColumn(knex, 'business_profile'); +}; + +// Irreversible by design: once normalized to the ISO code there's no +// way to know which `LI` rows were originally `FL`, and reverting would +// reintroduce the non-ISO value the rest of the system can't read. +exports.down = async function() {}; diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 6f178d66..dc95bc9f 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -245,6 +245,17 @@ router.post('/', [ body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }), body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }), body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }), + // At least one human-readable identifier so the record isn't a + // nameless row that's impossible to recognise in lists later. + body('prefill').custom((prefill) => { + const p = prefill || {}; + const hasName = ['company_name', 'display_name', 'first_name', 'last_name'] + .some((k) => typeof p[k] === 'string' && p[k].trim()); + if (!hasName) { + throw new Error('At least a company name or a contact name is required'); + } + return true; + }), ], handleAsync(async (req, res) => { validateRequest(req); const { id } = await customerAccountsService.createDirect({ diff --git a/frontend/src/components/admin/InlineCustomerCreate.tsx b/frontend/src/components/admin/InlineCustomerCreate.tsx index 41ad4b47..1bca4d5c 100644 --- a/frontend/src/components/admin/InlineCustomerCreate.tsx +++ b/frontend/src/components/admin/InlineCustomerCreate.tsx @@ -23,7 +23,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { Save, Send, X } from 'lucide-react'; -import { Button, Input } from '../common'; +import { Button, CountrySelect, Input } from '../common'; import { customerAdminService, type CustomerAccountDetail, @@ -136,28 +136,42 @@ export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mod staleTime: 5 * 60 * 1000, }); const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en'; + const profileCountryCode = profileSnapshot?.profile?.countryCode || ''; - // Seed preferredLanguage with the profile default once the profile - // arrives (only if the field is still empty so we don't clobber - // explicit user input). + // Seed preferredLanguage + countryCode with the profile defaults once + // the profile arrives (only if the field is still empty so we don't + // clobber explicit user input). React.useEffect(() => { - if (profileDefaultLocale && !form.preferredLanguage) { - setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale }); - } + setForm((prev) => { + const next = { ...prev }; + if (profileDefaultLocale && !prev.preferredLanguage) next.preferredLanguage = profileDefaultLocale; + if (profileCountryCode && !prev.countryCode) next.countryCode = profileCountryCode.toUpperCase(); + return next; + }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [profileDefaultLocale]); + }, [profileDefaultLocale, profileCountryCode]); const setField = (key: keyof FormState) => (e: React.ChangeEvent) => setForm((prev) => ({ ...prev, [key]: e.target.value })); - const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email); + const hasEmail = !!form.email && /\S+@\S+\.\S+/.test(form.email); + // At least one human-readable identifier so the record isn't a + // nameless row that's impossible to recognise in lists later. + const hasName = !!(form.companyName.trim() || form.displayName.trim() + || form.firstName.trim() || form.lastName.trim()); + const isValid = hasEmail && hasName; const handleSave = async (mode: 'passive' | 'invite') => { - if (!isValid) { + if (!hasEmail) { toast.error(t('customers.create.emailRequired', 'A valid email is required.')); return; } + if (!hasName) { + toast.error(t('customers.create.nameRequired', + 'Enter at least a company name or a contact name.')); + return; + } setBusy(mode); try { const customer = await customerAdminService.createDirect(form.email, buildPrefill(form)); @@ -298,12 +312,10 @@ export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mod value={form.state} onChange={setField('state')} /> - setForm((prev) => ({ ...prev, countryCode: code }))} />
- - setForm((prev) => ({ ...prev, countryCode: code }))} />
{/* Free-text country name override (migration 107). When left empty the PDF renderer falls back to the locale- - aware lookup on the abbreviation; useful when the - abbreviation isn't an ISO code (e.g. "FL" for - Liechtenstein, which is "LI" in ISO). */} + aware lookup on the ISO code. Kept for the rare case + where an operator wants a custom display name that + differs from the standard localized label. */}