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.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent 34685505be
commit be39929476
2 changed files with 94 additions and 3 deletions
@@ -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<Props> = ({ 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<CustomerAccountSummary[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Debounced search. Aborts in-flight requests so a fast typer doesn't
@@ -117,6 +127,14 @@ export const CustomerAccountPicker: React.FC<Props> = ({ 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<Props> = ({ value, onChange, disabl
</div>
)}
{isCreating ? (
<InlineCustomerCreate
// With the portal off, "Save & send portal invitation" would email
// the customer a link to a login that does not exist — offer only
// the passive record there.
mode={customerPortalEnabled ? 'both' : 'passive'}
onCancel={() => setIsCreating(false)}
onCreated={created}
/>
) : (
<>
{/* Search input */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none" />
@@ -186,6 +215,16 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
/>
</div>
{!disabled && canCreateCustomer && (
<button
type="button"
onClick={() => { setIsOpen(false); setIsCreating(true); }}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary-600 dark:text-primary-400 hover:underline"
>
{t('customers.create.openLink', '+ Create new customer')}
</button>
)}
{/* Dropdown */}
{isOpen && query.trim() !== '' && (
<div
@@ -197,7 +236,13 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{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.')}
</div>
) : (
<ul role="listbox">
@@ -217,6 +262,8 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
)}
</div>
)}
</>
)}
</div>
);
};
@@ -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<typeof import('react-i18next')>('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 }) => (
<div data-testid="inline-create">{mode}</div>
),
}));
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(<CustomerAccountPicker portalAssignment={false} value={[]} onChange={() => {}} />);
@@ -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(<CustomerAccountPicker portalAssignment={false} value={[]} onChange={() => {}} />);
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(<CustomerAccountPicker value={[]} onChange={() => {}} />);
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(<CustomerAccountPicker portalAssignment={false} value={[]} onChange={() => {}} />);
expect(screen.queryByText(CREATE_LINK)).not.toBeInTheDocument();
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
});
});