From be399294767d8cf029dff3664bdc120180c8be82 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 19:03:00 +0200 Subject: [PATCH] fix(accounting): allow creating a customer from the picker With Accounting on and CRM/customerPortal off, the picker renders but there was still no way to create the first customer: /admin/clients/accounts and every CRM editor with inline-create are feature-gated, and the picker's empty-state hint pointed at that unreachable page. Reuses the existing InlineCustomerCreate that CustomerPicker already mounts for the CRM editors. The affordance is gated on customers.create, matching the backend, where POST /admin/customers is permission-gated and not flag-gated. mode is 'passive' when customerPortal is off -- a portal invitation would email a link to a login that does not exist -- and 'both' when it is on. On success the customer is appended to the selection, which is what the accounting call sites' next.slice(-1) already expects. The noResults hint pointing at the hidden page is replaced by two keys: one naming the button, one for admins without the permission. Refs testplan REPORT.md B12. --- .../admin/CustomerAccountPicker.tsx | 49 ++++++++++++++++++- .../customerAccountPickerPortalGate.test.tsx | 48 +++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/admin/CustomerAccountPicker.tsx b/frontend/src/components/admin/CustomerAccountPicker.tsx index eda64a8e..4316f234 100644 --- a/frontend/src/components/admin/CustomerAccountPicker.tsx +++ b/frontend/src/components/admin/CustomerAccountPicker.tsx @@ -13,6 +13,8 @@ import { Search, X, UserPlus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { customerAdminService, type CustomerAccountSummary } from '../../services/customerAdmin.service'; import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext'; +import { usePermission } from '../../hooks/usePermission'; +import { InlineCustomerCreate } from './InlineCustomerCreate'; export interface SelectedCustomer { id: number; @@ -59,10 +61,18 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl // crash). That tanked the entire /admin/events/new page through // the global error boundary. PR #458 reviewer flag. const customerPortalEnabled = useFeatureEnabled('customerPortal'); + // Inline create is the ONLY way to reach POST /admin/customers on an + // Accounting-only install: /admin/clients/accounts and the CRM editors + // that embed InlineCustomerCreate are all feature-gated, while the + // endpoint itself is permission-gated only and exists precisely to + // create passive, portal-less customers. Mirror that with the + // permission rather than a flag. + const canCreateCustomer = usePermission('customers.create'); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [isOpen, setIsOpen] = useState(false); const [isSearching, setIsSearching] = useState(false); + const [isCreating, setIsCreating] = useState(false); const containerRef = useRef(null); // Debounced search. Aborts in-flight requests so a fast typer doesn't @@ -117,6 +127,14 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl onChange(value.filter((v) => v.id !== id)); }; + const created = (c: { id: number; email: string; displayName: string | null }) => { + onChange([...value, { id: c.id, email: c.email, displayName: c.displayName }]); + setIsCreating(false); + setQuery(''); + setResults([]); + setIsOpen(false); + }; + const helpText = useMemo( () => t( 'events.customerPicker.help', @@ -172,6 +190,17 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl )} + {isCreating ? ( + setIsCreating(false)} + onCreated={created} + /> + ) : ( + <> {/* Search input */}
@@ -186,6 +215,16 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl />
+ {!disabled && canCreateCustomer && ( + + )} + {/* Dropdown */} {isOpen && query.trim() !== '' && (
= ({ value, onChange, disabl
) : results.length === 0 ? (
- {t('events.customerPicker.noResults', 'No matches. Invite this customer from Clients → Accounts first.')} + {/* The old copy pointed at Clients → Accounts, which is + feature-gated and therefore unreachable on an + Accounting-only install. Point at the button that is + always right there instead. */} + {canCreateCustomer + ? t('events.customerPicker.noResultsCanCreate', 'No matches. Use “Create new customer” to add one.') + : t('events.customerPicker.noResultsNoPermission', 'No matches, and your role cannot create customers. Ask an administrator to add this customer.')}
) : (
    @@ -217,6 +262,8 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl )} )} + + )} ); }; diff --git a/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx b/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx index e78efcc2..b5bb56c1 100644 --- a/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx +++ b/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx @@ -9,8 +9,8 @@ * POST /admin/customers creates passive (portal-less) customers on purpose. */ import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next'); @@ -25,6 +25,19 @@ vi.mock('../../../contexts/FeatureFlagsContext', () => ({ useFeatureEnabled: () => portalEnabled, })); +let canCreate = true; +vi.mock('../../../hooks/usePermission', () => ({ + usePermission: () => canCreate, +})); + +// The inline create form is exercised by its own suites; stub it here so this +// file keeps testing only the gate + the create affordance. +vi.mock('../InlineCustomerCreate', () => ({ + InlineCustomerCreate: ({ mode }: { mode?: string }) => ( +
    {mode}
    + ), +})); + vi.mock('../../../services/customerAdmin.service', () => ({ customerAdminService: { search: vi.fn().mockResolvedValue([]) }, })); @@ -33,8 +46,11 @@ import { CustomerAccountPicker } from '../CustomerAccountPicker'; const SEARCH_PLACEHOLDER = 'Search by email, name, or company'; const PORTAL_LABEL = 'Customer accounts'; +const CREATE_LINK = '+ Create new customer'; describe('CustomerAccountPicker portal gate (QA S10)', () => { + beforeEach(() => { canCreate = true; }); + it('renders a usable search input with customerPortal off when portalAssignment=false', () => { portalEnabled = false; render( {}} />); @@ -59,4 +75,32 @@ describe('CustomerAccountPicker portal gate (QA S10)', () => { expect(screen.getByText(PORTAL_LABEL)).toBeInTheDocument(); expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument(); }); + + // B12 — on an Accounting-only install /admin/clients/accounts and every CRM + // editor that embeds InlineCustomerCreate are feature-gated, so the picker is + // the only place left that can reach POST /admin/customers. + it('offers inline create (passive-only) when the portal is off', () => { + portalEnabled = false; + render( {}} />); + + fireEvent.click(screen.getByText(CREATE_LINK)); + expect(screen.getByTestId('inline-create')).toHaveTextContent('passive'); + }); + + it('offers both save modes when the portal is on', () => { + portalEnabled = true; + render( {}} />); + + fireEvent.click(screen.getByText(CREATE_LINK)); + expect(screen.getByTestId('inline-create')).toHaveTextContent('both'); + }); + + it('hides the create affordance without customers.create', () => { + portalEnabled = false; + canCreate = false; + render( {}} />); + + expect(screen.queryByText(CREATE_LINK)).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument(); + }); });