feat(crm): country dropdown + name guard for customer create/edit
Replace the free-text 2-char country code field on the inline customer create form and the customer detail page with a dropdown that shows localized country names (Intl.DisplayNames, no hardcoded map) while still storing the ISO 3166-1 alpha-2 code. The create form now seeds the default country from the business profile instead of leaving it blank or guessing CH/FL. The free-text countryName override is kept for the rare case where an operator wants a custom display string. Standardize Liechtenstein on the ISO code LI instead of the colloquial plate code FL so it matches the PDF renderer's locale-aware lookup and the new dropdown. Migration 110 normalizes existing FL rows to LI on customer_accounts and business_profile (idempotent, case-insensitive). Require at least one human-readable identifier (company name or a contact name) at create time so the form can't produce a nameless row that's impossible to recognise in lists later. Enforced on both the frontend (isValid + toast) and the backend POST /admin/customers validator so the API can't be bypassed. i18n: en + de updated; other locales fall back to inline English defaults and should get a native review before release.
This commit is contained in:
@@ -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() {};
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Props> = ({ 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<HTMLInputElement | HTMLSelectElement>) =>
|
||||
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<Props> = ({ onCreated, onCancel, mod
|
||||
value={form.state}
|
||||
onChange={setField('state')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
|
||||
<CountrySelect
|
||||
label={t('customers.detail.country', 'Country') as string}
|
||||
value={form.countryCode}
|
||||
onChange={setField('countryCode')}
|
||||
placeholder="CH"
|
||||
maxLength={2}
|
||||
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { countryLabel, sortedCountryOptions } from '../../constants/countries';
|
||||
|
||||
/**
|
||||
* Country picker whose option labels are localized country names but
|
||||
* whose stored/emitted value is always the ISO 3166-1 alpha-2 code.
|
||||
* Labels come from `Intl.DisplayNames` in the active UI language, so the
|
||||
* list stays locale-aware without a hand-maintained translation map.
|
||||
*
|
||||
* A value that isn't in the curated list (e.g. legacy data) is preserved
|
||||
* as its own option so editing an existing record never silently drops it.
|
||||
*/
|
||||
interface CountrySelectProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
/** Label for the empty option; defaults to a translated placeholder. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const CountrySelect: React.FC<CountrySelectProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.language || 'en';
|
||||
const selectId = React.useId();
|
||||
|
||||
const options = sortedCountryOptions(lang);
|
||||
const current = (value || '').trim().toUpperCase();
|
||||
const hasCurrent = options.some((o) => o.code === current);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={clsx('input', error && 'border-red-500 focus-visible:ring-red-500')}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
aria-describedby={error ? `${selectId}-error` : undefined}
|
||||
>
|
||||
<option value="">{placeholder ?? t('common.selectCountry', 'Select country…')}</option>
|
||||
{!hasCurrent && current && (
|
||||
<option value={current}>{countryLabel(current, lang)}</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={`${selectId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
export { Button } from './Button';
|
||||
export { CMSContentBlock } from './CMSContentBlock';
|
||||
export { Input } from './Input';
|
||||
export { CountrySelect } from './CountrySelect';
|
||||
export { LocalizedDateInput } from './LocalizedDateInput';
|
||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||
export { Loading, LoadingSkeleton } from './Loading';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Country codes offered in the customer country picker. Stored value is
|
||||
* always the ISO 3166-1 alpha-2 code; Liechtenstein is `LI` (NOT the
|
||||
* colloquial `FL` plate code) so it matches ISO + the PDF renderer's
|
||||
* lookup. Display names are derived at runtime from `Intl.DisplayNames`
|
||||
* in the active UI language, so the list stays locale-aware without a
|
||||
* hand-maintained translation map.
|
||||
*/
|
||||
export const COUNTRY_CODES = [
|
||||
'LI', 'CH', 'AT', 'DE', 'FR', 'IT', 'ES', 'PT', 'NL', 'BE', 'LU',
|
||||
'GB', 'US', 'DK', 'SE', 'NO', 'FI', 'PL', 'CZ', 'SK', 'HU', 'IE',
|
||||
] as const;
|
||||
|
||||
export type CountryCode = (typeof COUNTRY_CODES)[number];
|
||||
|
||||
/** Localized country name for an ISO code, falling back to the code. */
|
||||
export function countryLabel(code: string, lang: string): string {
|
||||
if (!code) return '';
|
||||
const upper = code.trim().toUpperCase();
|
||||
try {
|
||||
return new Intl.DisplayNames([lang || 'en'], { type: 'region' }).of(upper) || upper;
|
||||
} catch {
|
||||
return upper;
|
||||
}
|
||||
}
|
||||
|
||||
/** Country codes sorted by their localized label for the active language. */
|
||||
export function sortedCountryOptions(lang: string): { code: string; label: string }[] {
|
||||
return COUNTRY_CODES.map((code) => ({ code, label: countryLabel(code, lang) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, lang || 'en'));
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"sortBy": "Sortieren nach",
|
||||
"selectCountry": "Land auswählen…",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"back": "Zurück",
|
||||
@@ -2997,7 +2998,8 @@
|
||||
"savedPassiveToast": "Passiver Kunde erstellt.",
|
||||
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
|
||||
"inviteFailedToast": "Kunde gespeichert (passiv). Einladungs-E-Mail fehlgeschlagen — bitte aus dem Kundendetail erneut versuchen.",
|
||||
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich."
|
||||
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
|
||||
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an."
|
||||
},
|
||||
"passive": {
|
||||
"badge": "Passiv — nur Admin",
|
||||
@@ -3102,6 +3104,7 @@
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland / Region",
|
||||
"countryCode": "Land (ISO-2)",
|
||||
"country": "Land",
|
||||
"countryName": "Land (vollständiger Name)",
|
||||
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
|
||||
"featuresSection": "Kundenfunktionen",
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"sortBy": "Sort by",
|
||||
"selectCountry": "Select country…",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"back": "Back",
|
||||
@@ -2997,7 +2998,8 @@
|
||||
"savedPassiveToast": "Passive customer created.",
|
||||
"savedActiveToast": "Customer created and portal invitation sent.",
|
||||
"inviteFailedToast": "Customer saved (passive). Invitation email failed — retry from the customer detail page.",
|
||||
"emailRequired": "A valid email is required."
|
||||
"emailRequired": "A valid email is required.",
|
||||
"nameRequired": "Enter at least a company name or a contact name."
|
||||
},
|
||||
"passive": {
|
||||
"badge": "Passive — admin only",
|
||||
@@ -3102,6 +3104,7 @@
|
||||
"city": "City",
|
||||
"state": "State / region",
|
||||
"countryCode": "Country (ISO 2)",
|
||||
"country": "Country",
|
||||
"countryName": "Country (full name)",
|
||||
"notesHint": "Visible only to admins. Never shown to the customer.",
|
||||
"featuresSection": "Customer features",
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
|
||||
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
|
||||
import { DecimalInput } from '../../components/common/DecimalInput';
|
||||
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
||||
@@ -538,20 +538,18 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
<Input value={form.state || ''} onChange={setField('state')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country abbreviation (FL, CH, DE …)')}</label>
|
||||
<Input
|
||||
<CountrySelect
|
||||
label={t('customers.detail.country', 'Country') as string}
|
||||
value={form.countryCode || ''}
|
||||
onChange={setField('countryCode')}
|
||||
maxLength={2}
|
||||
placeholder="FL"
|
||||
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* 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. */}
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryName', 'Country (full name)')}</label>
|
||||
<Input
|
||||
value={form.countryName || ''}
|
||||
|
||||
Reference in New Issue
Block a user