fix(accounting): let "bill to a customer" work with the portal off
CustomerAccountPicker returns null when customerPortal is off. That is right
for its original use -- the event form assigns portal logins that bypass the
gallery password -- but the Accounting flows reuse it as-is, so their required
"Client" field rendered a bare label with no input and the submit button could
never enable, with no explanation. Accounting-on + CRM-off is a valid,
UI-supported flag combination.
Took option (a): the bill-to-customer path does not depend on the portal.
POST /admin/expenses/:id/invoice is gated by requireExpenses + accounting.manage
only, and /admin/customers{,/search} are permission-gated rather than
flag-gated -- POST /admin/customers exists precisely to create passive,
portal-less customers "to attach a quote / invoice / gallery to". The
un-gated CustomerPicker used by the quote/bill/contract editors is the
precedent. (The comment claiming search 410s with the flag off was stale.)
Add portalAssignment (default true) so the gate and the portal-specific
label/help text apply only in event-assignment mode; the accounting call
sites render their own label. Event-form behaviour is unchanged.
Also fixes AccountingInboxPage's TriageModal, which has the identical
label-only failure on the rebill disposition from the same root cause --
outside the reported surface, but leaving it would half-fix the bug.
Refs testplan REPORT.md #7 (Part 8, S10).
This commit is contained in:
@@ -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<Props> = ({ value, onChange, disabled }) => {
|
||||
export const CustomerAccountPicker: React.FC<Props> = ({ 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<Props> = ({ 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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('events.customerPicker.label', 'Customer accounts')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{helpText}</p>
|
||||
{portalAssignment && (
|
||||
<>
|
||||
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('events.customerPicker.label', 'Customer accounts')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{helpText}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Selected chips */}
|
||||
{value.length > 0 && (
|
||||
|
||||
@@ -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<typeof import('react-i18next')>('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(<CustomerAccountPicker portalAssignment={false} value={[]} onChange={() => {}} />);
|
||||
|
||||
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(<CustomerAccountPicker value={[]} onChange={() => {}} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('keeps the portal label + help text on the event form when customerPortal is on', () => {
|
||||
portalEnabled = true;
|
||||
render(<CustomerAccountPicker value={[]} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByText(PORTAL_LABEL)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user