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 { useTranslation } from 'react-i18next';
|
||||||
import { customerAdminService, type CustomerAccountSummary } from '../../services/customerAdmin.service';
|
import { customerAdminService, type CustomerAccountSummary } from '../../services/customerAdmin.service';
|
||||||
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
|
||||||
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { InlineCustomerCreate } from './InlineCustomerCreate';
|
||||||
|
|
||||||
export interface SelectedCustomer {
|
export interface SelectedCustomer {
|
||||||
id: number;
|
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
|
// crash). That tanked the entire /admin/events/new page through
|
||||||
// the global error boundary. PR #458 reviewer flag.
|
// the global error boundary. PR #458 reviewer flag.
|
||||||
const customerPortalEnabled = useFeatureEnabled('customerPortal');
|
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 [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<CustomerAccountSummary[]>([]);
|
const [results, setResults] = useState<CustomerAccountSummary[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Debounced search. Aborts in-flight requests so a fast typer doesn't
|
// 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));
|
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(
|
const helpText = useMemo(
|
||||||
() => t(
|
() => t(
|
||||||
'events.customerPicker.help',
|
'events.customerPicker.help',
|
||||||
@@ -172,6 +190,17 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
|
|||||||
</div>
|
</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 */}
|
{/* Search input */}
|
||||||
<div className="relative">
|
<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" />
|
<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>
|
</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 */}
|
{/* Dropdown */}
|
||||||
{isOpen && query.trim() !== '' && (
|
{isOpen && query.trim() !== '' && (
|
||||||
<div
|
<div
|
||||||
@@ -197,7 +236,13 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
|
|||||||
</div>
|
</div>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul role="listbox">
|
<ul role="listbox">
|
||||||
@@ -217,6 +262,8 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
* POST /admin/customers creates passive (portal-less) customers on purpose.
|
* POST /admin/customers creates passive (portal-less) customers on purpose.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
vi.mock('react-i18next', async () => {
|
vi.mock('react-i18next', async () => {
|
||||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
@@ -25,6 +25,19 @@ vi.mock('../../../contexts/FeatureFlagsContext', () => ({
|
|||||||
useFeatureEnabled: () => portalEnabled,
|
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', () => ({
|
vi.mock('../../../services/customerAdmin.service', () => ({
|
||||||
customerAdminService: { search: vi.fn().mockResolvedValue([]) },
|
customerAdminService: { search: vi.fn().mockResolvedValue([]) },
|
||||||
}));
|
}));
|
||||||
@@ -33,8 +46,11 @@ import { CustomerAccountPicker } from '../CustomerAccountPicker';
|
|||||||
|
|
||||||
const SEARCH_PLACEHOLDER = 'Search by email, name, or company';
|
const SEARCH_PLACEHOLDER = 'Search by email, name, or company';
|
||||||
const PORTAL_LABEL = 'Customer accounts';
|
const PORTAL_LABEL = 'Customer accounts';
|
||||||
|
const CREATE_LINK = '+ Create new customer';
|
||||||
|
|
||||||
describe('CustomerAccountPicker portal gate (QA S10)', () => {
|
describe('CustomerAccountPicker portal gate (QA S10)', () => {
|
||||||
|
beforeEach(() => { canCreate = true; });
|
||||||
|
|
||||||
it('renders a usable search input with customerPortal off when portalAssignment=false', () => {
|
it('renders a usable search input with customerPortal off when portalAssignment=false', () => {
|
||||||
portalEnabled = false;
|
portalEnabled = false;
|
||||||
render(<CustomerAccountPicker portalAssignment={false} value={[]} onChange={() => {}} />);
|
render(<CustomerAccountPicker portalAssignment={false} value={[]} onChange={() => {}} />);
|
||||||
@@ -59,4 +75,32 @@ describe('CustomerAccountPicker portal gate (QA S10)', () => {
|
|||||||
expect(screen.getByText(PORTAL_LABEL)).toBeInTheDocument();
|
expect(screen.getByText(PORTAL_LABEL)).toBeInTheDocument();
|
||||||
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).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