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:
Luca
2026-06-02 01:28:00 +02:00
parent 840df52581
commit db2c482ae9
9 changed files with 202 additions and 26 deletions
@@ -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">