/** * Inline "+ Create new customer" form mounted inside the quote / * invoice editor's customer card. * * Two save modes: * - "Save as passive customer" → POST /admin/customers. No email, * no invitation. The customer becomes a usable record * immediately for the current quote/invoice. * - "Save & send portal invitation" → POST /admin/customers * followed by POST /admin/customers/:id/send-invite. Customer is * created (passive in DB), then a standard onboarding email is * queued so they can claim portal access. Orchestrated client- * side so the backend endpoints stay simple and single-purpose. * * If the second call fails after the first succeeds, the customer * stays saved (passive) and a warning toast asks the admin to retry * from the customer detail page. * * #1261 — the toasts here say what actually happened rather than what was * intended. Two calls means three outcomes, and the middle one used to be * indistinguishable from success: the invitation email is only QUEUED, so * "invitation sent" was a claim this code cannot make. * * Field set mirrors the customer detail page so admins see the same * shape regardless of where they're editing. */ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { Save, Send, X } from 'lucide-react'; import { Button, CountrySelect, Input } from '../common'; import { customerAdminService, type CustomerAccountDetail, type CustomerInvitePrefill, } from '../../services/customerAdmin.service'; import { businessProfileService } from '../../services/businessProfile.service'; import { useQuery } from '@tanstack/react-query'; interface Props { /** * Fires after a successful save. The customer payload is the same * shape the customer-detail endpoint returns, so callers can use * its id/email/company directly to populate the quote/invoice's * customer pin. */ onCreated: (customer: CustomerAccountDetail) => void; /** Revert the editor card back to the search-only state. */ onCancel: () => void; /** * Which save action(s) the form should expose. * - 'both' (default): renders both buttons — used by the quote / * invoice editors where the admin picks the mode in-place. * - 'passive': renders only "Save as passive customer". * - 'invite': renders only "Save & send portal invitation". * The "passive" / "invite" specialisations let CustomerManagementPage * route both header buttons through the same modal — the only * difference between the two flows is which action button shows. */ mode?: 'both' | 'passive' | 'invite'; } type FormState = { email: string; salutation: string; firstName: string; lastName: string; displayName: string; phone: string; companyName: string; vatId: string; addressLine1: string; addressLine2: string; postalCode: string; city: string; state: string; countryCode: string; preferredLanguage: string; }; const empty: FormState = { email: '', salutation: '', firstName: '', lastName: '', displayName: '', phone: '', companyName: '', vatId: '', addressLine1: '', addressLine2: '', postalCode: '', city: '', state: '', countryCode: '', preferredLanguage: '', }; function buildPrefill(f: FormState): CustomerInvitePrefill { // Backend's PREFILLABLE_FIELDS uses snake_case. Translate at the // wire boundary and drop empty strings so the server doesn't store // "" where null would be more honest. const out: CustomerInvitePrefill = {}; if (f.salutation) out.salutation = f.salutation; if (f.firstName) out.first_name = f.firstName; if (f.lastName) out.last_name = f.lastName; if (f.displayName) out.display_name = f.displayName; if (f.phone) out.phone = f.phone; if (f.companyName) out.company_name = f.companyName; if (f.vatId) out.vat_id = f.vatId; if (f.addressLine1) out.address_line1 = f.addressLine1; if (f.addressLine2) out.address_line2 = f.addressLine2; if (f.postalCode) out.postal_code = f.postalCode; if (f.city) out.city = f.city; if (f.state) out.state = f.state; if (f.countryCode) out.country_code = f.countryCode.toUpperCase(); if (f.preferredLanguage) out.preferred_language = f.preferredLanguage; return out; } export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mode = 'both' }) => { const { t } = useTranslation(); const [form, setForm] = useState(empty); const [busy, setBusy] = useState<'passive' | 'invite' | null>(null); // Resolve a title + subtitle that matches the selected mode. The // 'both' branch keeps the legacy copy so inline (in-editor) callers // see the same wording they had before this prop existed. const heading = mode === 'invite' ? { title: t('customers.invite.title', 'Invite a customer'), subtitle: t('customers.invite.description', 'They will receive an email with a link to set up their account. Once they have accepted, you can assign them to events.'), } : mode === 'passive' ? { title: t('customers.create.openButton', 'Create passive customer'), subtitle: t('customers.create.passiveSubtitle', 'Adds an admin-only customer record. The customer is not notified and cannot log in until you send them an invitation later.'), } : { title: t('customers.create.title', 'Create new customer'), subtitle: t('customers.create.subtitle', 'Fill in the details below. Choose "Save as passive customer" to create an admin-only record, or "Save & send portal invitation" to also email the customer a sign-up link.'), }; // Business-profile default locale powers the preferred-language // hint AND seeds the field on mount. const { data: profileSnapshot } = useQuery({ queryKey: ['business-profile-snapshot'], queryFn: () => businessProfileService.get(), staleTime: 5 * 60 * 1000, }); const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en'; const profileCountryCode = profileSnapshot?.profile?.countryCode || ''; // 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(() => { 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, profileCountryCode]); const setField = (key: keyof FormState) => (e: React.ChangeEvent) => setForm((prev) => ({ ...prev, [key]: e.target.value })); 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 (!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)); if (mode === 'invite') { // Customer is now saved as passive. Fire the second call to // promote them. If THIS fails, keep the customer selected // (it exists, just no email went out) and warn the admin. try { await customerAdminService.sendInvite(customer.id); toast.success(t('customers.create.savedActiveToast', 'Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.')); } catch (err: any) { // Four different things reach this branch and they need four // different answers. Only ONE of them means "nothing happened, go // ahead and retry" — every other message that says so sends the // admin into a retry that then 409s. const status = err?.response?.status; const code = err?.response?.data?.code; if (code === 'CUSTOMER_ALREADY_ACTIVE') { // This address already has portal access — either the route's own // check, or an invitation accepted between it and the service's // recheck. Nothing to cancel, nothing to resend. toast.info(t('customers.create.inviteAlreadyActiveToast', 'Customer saved. This address already has portal access, so no invitation was needed.')); } else if (status === 409) { // An invitation for this address is already open and the RE-invite // was refused. The customer IS invited. toast.warn(t('customers.create.inviteAlreadyPendingToast', 'Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.')); } else if (!err?.response || status >= 500) { // No response (dropped connection, timeout) or a server error. // Neither says the invitation was not created: createInvitation // inserts the customer_invitations row and only then queues the // email, without a transaction, so a 500 out of the queueing step // leaves an open invitation behind. Asserting a clean failure here // sends the admin into a retry that 409s and still queues nothing. toast.warn(t('customers.create.inviteUnconfirmedToast', 'Customer saved, but the invitation could not be confirmed — check the Invitations tab before sending another.')); } else { // A 4xx that is not a conflict: validation, permissions, no such // customer. The request was rejected before anything was written. toast.warn(t('customers.create.inviteFailedToast', 'Customer saved as PASSIVE — no invitation went out. Retry "Send portal invitation" from the customer detail page.')); } // eslint-disable-next-line no-console console.warn('sendInvite failed', err); } } else { toast.success(t('customers.create.savedPassiveToast', 'Passive customer created.')); } onCreated(customer); } catch (err: any) { const msg = err?.response?.data?.error || err?.message || t('common.error', 'Something went wrong.'); toast.error(String(msg)); } finally { setBusy(null); } }; return (

{heading.title}

{heading.subtitle}

{/* Salutation values are stored verbatim ("Herr", "Frau", "Mx", "Dr") — canonical tokens across locales. Display labels are translated; the option's value stays in the German form so the same key works regardless of which locale the admin is editing in. Matches CustomerDetailPage. */}
setForm((prev) => ({ ...prev, countryCode: code }))} />
{(mode === 'both' || mode === 'passive') && ( )} {(mode === 'both' || mode === 'invite') && ( )}
); };