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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user