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_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
|
||||||
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
|
||||||
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
|
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) => {
|
], handleAsync(async (req, res) => {
|
||||||
validateRequest(req);
|
validateRequest(req);
|
||||||
const { id } = await customerAccountsService.createDirect({
|
const { id } = await customerAccountsService.createDirect({
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import React, { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Save, Send, X } from 'lucide-react';
|
import { Save, Send, X } from 'lucide-react';
|
||||||
import { Button, Input } from '../common';
|
import { Button, CountrySelect, Input } from '../common';
|
||||||
import {
|
import {
|
||||||
customerAdminService,
|
customerAdminService,
|
||||||
type CustomerAccountDetail,
|
type CustomerAccountDetail,
|
||||||
@@ -136,28 +136,42 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
|
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
|
||||||
|
const profileCountryCode = profileSnapshot?.profile?.countryCode || '';
|
||||||
|
|
||||||
// Seed preferredLanguage with the profile default once the profile
|
// Seed preferredLanguage + countryCode with the profile defaults once
|
||||||
// arrives (only if the field is still empty so we don't clobber
|
// the profile arrives (only if the field is still empty so we don't
|
||||||
// explicit user input).
|
// clobber explicit user input).
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (profileDefaultLocale && !form.preferredLanguage) {
|
setForm((prev) => {
|
||||||
setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [profileDefaultLocale]);
|
}, [profileDefaultLocale, profileCountryCode]);
|
||||||
|
|
||||||
const setField = (key: keyof FormState) =>
|
const setField = (key: keyof FormState) =>
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||||
setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
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') => {
|
const handleSave = async (mode: 'passive' | 'invite') => {
|
||||||
if (!isValid) {
|
if (!hasEmail) {
|
||||||
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
|
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!hasName) {
|
||||||
|
toast.error(t('customers.create.nameRequired',
|
||||||
|
'Enter at least a company name or a contact name.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(mode);
|
setBusy(mode);
|
||||||
try {
|
try {
|
||||||
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
|
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}
|
value={form.state}
|
||||||
onChange={setField('state')}
|
onChange={setField('state')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<CountrySelect
|
||||||
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
|
label={t('customers.detail.country', 'Country') as string}
|
||||||
value={form.countryCode}
|
value={form.countryCode}
|
||||||
onChange={setField('countryCode')}
|
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
|
||||||
placeholder="CH"
|
|
||||||
maxLength={2}
|
|
||||||
/>
|
/>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
<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 { Button } from './Button';
|
||||||
export { CMSContentBlock } from './CMSContentBlock';
|
export { CMSContentBlock } from './CMSContentBlock';
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
|
export { CountrySelect } from './CountrySelect';
|
||||||
export { LocalizedDateInput } from './LocalizedDateInput';
|
export { LocalizedDateInput } from './LocalizedDateInput';
|
||||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||||
export { Loading, LoadingSkeleton } from './Loading';
|
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",
|
"edit": "Bearbeiten",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"sortBy": "Sortieren nach",
|
"sortBy": "Sortieren nach",
|
||||||
|
"selectCountry": "Land auswählen…",
|
||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"no": "Nein",
|
"no": "Nein",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
@@ -2997,7 +2998,8 @@
|
|||||||
"savedPassiveToast": "Passiver Kunde erstellt.",
|
"savedPassiveToast": "Passiver Kunde erstellt.",
|
||||||
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
|
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
|
||||||
"inviteFailedToast": "Kunde gespeichert (passiv). Einladungs-E-Mail fehlgeschlagen — bitte aus dem Kundendetail erneut versuchen.",
|
"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": {
|
"passive": {
|
||||||
"badge": "Passiv — nur Admin",
|
"badge": "Passiv — nur Admin",
|
||||||
@@ -3102,6 +3104,7 @@
|
|||||||
"city": "Stadt",
|
"city": "Stadt",
|
||||||
"state": "Bundesland / Region",
|
"state": "Bundesland / Region",
|
||||||
"countryCode": "Land (ISO-2)",
|
"countryCode": "Land (ISO-2)",
|
||||||
|
"country": "Land",
|
||||||
"countryName": "Land (vollständiger Name)",
|
"countryName": "Land (vollständiger Name)",
|
||||||
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
|
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
|
||||||
"featuresSection": "Kundenfunktionen",
|
"featuresSection": "Kundenfunktionen",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"sortBy": "Sort by",
|
"sortBy": "Sort by",
|
||||||
|
"selectCountry": "Select country…",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
@@ -2997,7 +2998,8 @@
|
|||||||
"savedPassiveToast": "Passive customer created.",
|
"savedPassiveToast": "Passive customer created.",
|
||||||
"savedActiveToast": "Customer created and portal invitation sent.",
|
"savedActiveToast": "Customer created and portal invitation sent.",
|
||||||
"inviteFailedToast": "Customer saved (passive). Invitation email failed — retry from the customer detail page.",
|
"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": {
|
"passive": {
|
||||||
"badge": "Passive — admin only",
|
"badge": "Passive — admin only",
|
||||||
@@ -3102,6 +3104,7 @@
|
|||||||
"city": "City",
|
"city": "City",
|
||||||
"state": "State / region",
|
"state": "State / region",
|
||||||
"countryCode": "Country (ISO 2)",
|
"countryCode": "Country (ISO 2)",
|
||||||
|
"country": "Country",
|
||||||
"countryName": "Country (full name)",
|
"countryName": "Country (full name)",
|
||||||
"notesHint": "Visible only to admins. Never shown to the customer.",
|
"notesHint": "Visible only to admins. Never shown to the customer.",
|
||||||
"featuresSection": "Customer features",
|
"featuresSection": "Customer features",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
} from 'lucide-react';
|
} 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 { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
|
||||||
import { DecimalInput } from '../../components/common/DecimalInput';
|
import { DecimalInput } from '../../components/common/DecimalInput';
|
||||||
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
||||||
@@ -538,20 +538,18 @@ export const CustomerDetailPage: React.FC = () => {
|
|||||||
<Input value={form.state || ''} onChange={setField('state')} />
|
<Input value={form.state || ''} onChange={setField('state')} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country abbreviation (FL, CH, DE …)')}</label>
|
<CountrySelect
|
||||||
<Input
|
label={t('customers.detail.country', 'Country') as string}
|
||||||
value={form.countryCode || ''}
|
value={form.countryCode || ''}
|
||||||
onChange={setField('countryCode')}
|
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
|
||||||
maxLength={2}
|
|
||||||
placeholder="FL"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{/* Free-text country name override (migration 107). When
|
{/* Free-text country name override (migration 107). When
|
||||||
left empty the PDF renderer falls back to the locale-
|
left empty the PDF renderer falls back to the locale-
|
||||||
aware lookup on the abbreviation; useful when the
|
aware lookup on the ISO code. Kept for the rare case
|
||||||
abbreviation isn't an ISO code (e.g. "FL" for
|
where an operator wants a custom display name that
|
||||||
Liechtenstein, which is "LI" in ISO). */}
|
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>
|
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryName', 'Country (full name)')}</label>
|
||||||
<Input
|
<Input
|
||||||
value={form.countryName || ''}
|
value={form.countryName || ''}
|
||||||
|
|||||||
Reference in New Issue
Block a user