diff --git a/frontend/src/components/admin/CustomerAccountPicker.tsx b/frontend/src/components/admin/CustomerAccountPicker.tsx index 9d2f710a..eda64a8e 100644 --- a/frontend/src/components/admin/CustomerAccountPicker.tsx +++ b/frontend/src/components/admin/CustomerAccountPicker.tsx @@ -24,6 +24,21 @@ interface Props { value: SelectedCustomer[]; onChange: (next: SelectedCustomer[]) => void; disabled?: boolean; + /** + * Event-form mode (default): this picker IS part of the customer-portal + * feature — it assigns portal logins to a gallery, so it hides itself + * when `customerPortal` is off and explains the password bypass. + * + * Pass false where the picker only needs to identify an existing + * customer record (Accounting → "bill this to a client"). Those + * surfaces have their own gates (`accounting` / `expenses` / + * `incomingInvoices`) and their data path never touches the portal: + * /admin/customers{,/search} are permission-gated, not flag-gated, and + * POST /admin/customers explicitly creates passive, portal-less + * customers "to attach a quote / invoice / gallery to". Callers in this + * mode render their own field label. + */ + portalAssignment?: boolean; } const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => { @@ -31,7 +46,7 @@ const labelFor = (c: { email: string; displayName?: string | null; companyName?: return display ? `${display} · ${c.email}` : c.email; }; -export const CustomerAccountPicker: React.FC = ({ value, onChange, disabled }) => { +export const CustomerAccountPicker: React.FC = ({ value, onChange, disabled, portalAssignment = true }) => { const { t } = useTranslation(); // Rules of Hooks: the feature-flag gate (early-return) is moved to // the very end of this hook list (see end of function). The previous @@ -111,19 +126,24 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl ); // Feature-flag gate (deliberately placed AFTER all hooks — see the - // long comment at the top of this component for why). When the - // customerPortal flag is off the backend returns 410 on - // /admin/customers/search anyway, but hiding the UI here keeps the - // event form clean and removes the dangling "Customer accounts" - // label that would otherwise appear above an empty placeholder. - if (!customerPortalEnabled) return null; + // long comment at the top of this component for why). Only applies to + // the event-assignment mode: hiding the UI there keeps the event form + // clean and removes the dangling "Customer accounts" label that would + // otherwise appear above an empty placeholder. Non-portal call sites + // must NOT be gated — their required customer field would render as a + // lone label with no input at all (QA S10). + if (portalAssignment && !customerPortalEnabled) return null; return (
- -

{helpText}

+ {portalAssignment && ( + <> + +

{helpText}

+ + )} {/* Selected chips */} {value.length > 0 && ( diff --git a/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx b/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx new file mode 100644 index 00000000..e78efcc2 --- /dev/null +++ b/frontend/src/components/admin/__tests__/customerAccountPickerPortalGate.test.tsx @@ -0,0 +1,62 @@ +/** + * The Accounting "bill this to a client" modals reuse CustomerAccountPicker, + * which used to hide itself whenever `customerPortal` was off — the default. + * The required field then rendered as a lone label with no input and the + * submit button could never enable (QA S10). + * + * Accounting/customerPortal is a supported flag combination: /admin/customers + * and /admin/customers/search are permission-gated, not flag-gated, and + * 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'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k) }), + }; +}); + +let portalEnabled = false; +vi.mock('../../../contexts/FeatureFlagsContext', () => ({ + useFeatureEnabled: () => portalEnabled, +})); + +vi.mock('../../../services/customerAdmin.service', () => ({ + customerAdminService: { search: vi.fn().mockResolvedValue([]) }, +})); + +import { CustomerAccountPicker } from '../CustomerAccountPicker'; + +const SEARCH_PLACEHOLDER = 'Search by email, name, or company'; +const PORTAL_LABEL = 'Customer accounts'; + +describe('CustomerAccountPicker portal gate (QA S10)', () => { + it('renders a usable search input with customerPortal off when portalAssignment=false', () => { + portalEnabled = false; + render( {}} />); + + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument(); + // The caller renders its own field label ("Client *"), so the portal + // label + gallery-password help text stay out of the way. + expect(screen.queryByText(PORTAL_LABEL)).not.toBeInTheDocument(); + }); + + it('still hides itself entirely on the event form when customerPortal is off', () => { + portalEnabled = false; + const { container } = render( {}} />); + + expect(container).toBeEmptyDOMElement(); + }); + + it('keeps the portal label + help text on the event form when customerPortal is on', () => { + portalEnabled = true; + render( {}} />); + + expect(screen.getByText(PORTAL_LABEL)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 3b8f1b8c..0db463c2 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -295,7 +295,11 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ {BOOKING_DISPOSITIONS.includes(disposition) && (
- setCustomer(next.slice(-1))} /> + {/* portalAssignment={false} — same reason as the expenses + ledger: this is an `incomingInvoices` flow, not a + customer-portal one, and the rebill disposition's + required field would otherwise render label-only. */} + setCustomer(next.slice(-1))} /> {disposition === 'durchlaufend' &&

{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}

}
{/* Markup is a re-bill concept only. A pass-through is invoiced diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx index 67e6021d..660811f1 100644 --- a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx +++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx @@ -209,7 +209,11 @@ const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onD

{t('accounting.ledger.invoiceHint', 'This creates a billable line on the client’s next scheduled invoice and locks the expense from further edits.')}

- setCustomer(next.slice(-1))} /> + {/* portalAssignment={false}: re-billing an expense is an Accounting + flow gated by `expenses`, not by the customer portal — without + this the required field renders a bare label and the submit + button can never enable (QA S10). */} + setCustomer(next.slice(-1))} />