Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/batch-update-businesses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@accounter/server': minor
'@accounter/client': minor
---

Batch-update selected businesses from the businesses table.

Client: the businesses table's selection-column header gains a bulk-actions menu, mirroring the
charges table's. "Update fields" opens a dialog for locality (country/city/zip), sort code, default
tax category, IRS code, suggestion description and the boolean flags (is active, receipt enough,
docs optional, VAT optional, exempt dealer) — only the fields filled in are applied, flags are
tri-state so "No change" leaves each business's own value alone. Sort code and tax category are now
pickers rather than raw number/UUID inputs. "Change tags" opens an Add/Remove dialog for suggestion
tags. The previous footer "Batch update" button moves into this menu.

Server: new `batchUpdateBusinessesTags(businessIds: [UUID!]!, addTagIds: [UUID!], removeTagIds:
[UUID!])` mutation, the businesses counterpart of `batchUpdateChargesTags`. It adds and/or removes
the given suggestion tags on every listed business while leaving each business's other tags
untouched; an id passed in both lists ends up added.

`batchUpdateBusinesses` no longer updates one business at a time. Every touched table now gets a
single statement for the whole selection (`businesses`, `financial_entities`, and an upsert into
`business_tax_category_match`) plus one read-back, so the query count is fixed regardless of how
many businesses are selected. Business suggestion tags live inside the `suggestion_data` JSON blob
rather than a join table, so `batchUpdateBusinessesTags` performs its set arithmetic in SQL and
costs a single statement as well.

**Breaking:** `BatchUpdateBusinessInput.suggestions: SuggestionsInput` is replaced by
`suggestionDescription: String`. The removed input accepted `phrases`, `emails` and `emailListener`,
which are per-business by nature and meaningless when applied wholesale, and its `tags` replaced a
business's entire tag set — use `batchUpdateBusinessesTags` for additive/subtractive tag changes.
128 changes: 128 additions & 0 deletions packages/client/src/components/businesses/batch-tags-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useState, type ReactElement } from 'react';
import { useBatchUpdateBusinessesTags } from '../../hooks/use-batch-update-businesses-tags.js';
import { useGetTags } from '../../hooks/use-get-tags.js';
import { MultiSelect } from '../common/index.js';
import { Button } from '../ui/button.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog.js';
import { Label } from '../ui/label.js';
import { Tabs, TabsList, TabsTrigger } from '../ui/tabs.js';

type Mode = 'add' | 'remove';

interface Props {
/** Business ids the tag change is applied to (the table's selected rows). */
businessIds: string[];
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful apply, so the caller can refresh the table. */
onDone: () => void;
}

/**
* Batch add or remove suggestion tags across the selected businesses. "Add" mode adds the chosen
* tags to every selected business (keeping their other tags); "Remove" mode removes them. Mirrors
* {@link ChargesBatchTagsDialog}, backed by the `batchUpdateBusinessesTags` mutation.
*/
export function BatchUpdateBusinessesTagsDialog({
businessIds,
open,
onOpenChange,
onDone,
}: Props): ReactElement {
const { selectableTags, fetching: fetchingTags } = useGetTags();
const { fetching, batchUpdateBusinessesTags } = useBatchUpdateBusinessesTags();
const [mode, setMode] = useState<Mode>('add');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
// Bumped to force-remount the (internally stateful) MultiSelect, clearing its selection.
const [resetNonce, setResetNonce] = useState(0);

const count = businessIds.length;

const reset = (): void => {
setSelectedTags([]);
setResetNonce(nonce => nonce + 1);
};

const onModeChange = (value: string): void => {
setMode(value as Mode);
reset();
};

// Single close path so both the Dialog's own dismissals (overlay/esc/X) and the Cancel button
// clear the selection before closing.
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
reset();
}
onOpenChange(nextOpen);
};

const onApply = async (): Promise<void> => {
if (selectedTags.length === 0 || count === 0) {
return;
}
const res = await batchUpdateBusinessesTags({
businessIds,
...(mode === 'add' ? { addTagIds: selectedTags } : { removeTagIds: selectedTags }),
});
if (res) {
reset();
onOpenChange(false);
onDone();
}
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>
Change tags for {count} business{count === 1 ? '' : 'es'}
</DialogTitle>
<DialogDescription>
Add the selected tags to, or remove them from, every selected business. Other tags are
left unchanged.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<Tabs value={mode} onValueChange={onModeChange}>
<TabsList className="w-full">
<TabsTrigger value="add">Add tags</TabsTrigger>
<TabsTrigger value="remove">Remove tags</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid gap-1">
<Label>Tags</Label>
<MultiSelect
key={`${mode}-${resetNonce}`}
options={selectableTags}
onValueChange={setSelectedTags}
defaultValue={selectedTags}
placeholder={mode === 'add' ? 'Select tags to add' : 'Select tags to remove'}
variant="default"
disabled={fetchingTags}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => void onApply()}
disabled={fetching || selectedTags.length === 0 || count === 0}
>
{mode === 'add' ? 'Add to' : 'Remove from'} {count} business{count === 1 ? '' : 'es'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
120 changes: 68 additions & 52 deletions packages/client/src/components/businesses/batch-update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState, type ReactElement } from 'react';
import { useContext, useState, type ReactElement } from 'react';
import type { BatchUpdateBusinessInput } from '../../gql/graphql.js';
import { useBatchUpdateBusinesses } from '../../hooks/use-batch-update-businesses.js';
import { useAllCountries } from '../../hooks/use-get-countries.js';
import { useGetTags } from '../../hooks/use-get-tags.js';
import { useGetTaxCategories } from '../../hooks/use-get-tax-categories.js';
import { cn } from '../../lib/utils.js';
import { ComboBox, MultiSelect } from '../common/index.js';
import { UserContext } from '../../providers/user-provider.js';
import { ComboBox, SortCodeSelect } from '../common/index.js';
import { Button } from '../ui/button.js';
import {
Dialog,
Expand All @@ -13,14 +14,16 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog.js';
import { Input } from '../ui/input.js';
import { Label } from '../ui/label.js';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';

interface BatchUpdateBusinessesDialogProps {
businessIds: string[];
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful apply, so the caller can refresh the table. */
onDone: () => void;
}

Expand All @@ -38,8 +41,7 @@ type FormState = {
sortCode: string;
irsCode: string;
taxCategory: string;
description: string;
tags: string[];
suggestionDescription: string;
} & Record<FlagKey, TriState>;

const EMPTY_FORM: FormState = {
Expand All @@ -49,30 +51,26 @@ const EMPTY_FORM: FormState = {
sortCode: '',
irsCode: '',
taxCategory: '',
description: '',
tags: [],
suggestionDescription: '',
isActive: 'unset',
isReceiptEnough: 'unset',
isDocumentsOptional: 'unset',
optionalVAT: 'unset',
exemptDealer: 'unset',
};

// `country` is rendered separately as a searchable ComboBox; the rest are simple inputs.
// Free-text fields. Country, sort code and tax category are rendered separately as pickers.
// `fullWidth` fields span both columns on wider screens.
const FIELDS: {
key: keyof FormState;
label: string;
placeholder?: string;
numeric?: boolean;
fullWidth?: boolean;
}[] = [
{ key: 'city', label: 'City' },
{ key: 'zipCode', label: 'Zip code' },
{ key: 'sortCode', label: 'Sort code', numeric: true },
{ key: 'irsCode', label: 'IRS code', numeric: true },
{ key: 'taxCategory', label: 'Tax category (UUID)', fullWidth: true },
{ key: 'description', label: 'Suggestion description', fullWidth: true },
{ key: 'suggestionDescription', label: 'Suggestion description', fullWidth: true },
];

// boolean flags rendered as tri-state selects.
Expand Down Expand Up @@ -108,16 +106,8 @@ function buildFields(form: FormState): BatchUpdateBusinessInput {
if (form.taxCategory.trim()) {
fields.taxCategory = form.taxCategory.trim();
}

const suggestions: NonNullable<BatchUpdateBusinessInput['suggestions']> = {};
if (form.description.trim()) {
suggestions.description = form.description.trim();
}
if (form.tags.length > 0) {
suggestions.tags = form.tags.map(id => ({ id }));
}
if (Object.keys(suggestions).length > 0) {
fields.suggestions = suggestions;
if (form.suggestionDescription.trim()) {
fields.suggestionDescription = form.suggestionDescription.trim();
}

for (const { key } of FLAG_FIELDS) {
Expand All @@ -129,46 +119,63 @@ function buildFields(form: FormState): BatchUpdateBusinessInput {
return fields;
}

/**
* Batch-edit the shared fields of the selected businesses: locality (country/city/zip), sort code,
* default tax category, IRS code, the suggestion description, and the boolean flags. Only the
* fields the user actually fills in are sent, so everything else keeps each business's own value.
* Tags are handled separately by {@link BatchUpdateBusinessesTagsDialog}, which needs add/remove
* semantics rather than a single shared value.
*/
export function BatchUpdateBusinessesDialog({
businessIds,
open,
onOpenChange,
onDone,
}: BatchUpdateBusinessesDialogProps): ReactElement {
const [open, setOpen] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const { userContext } = useContext(UserContext);
const { fetching, batchUpdateBusinesses } = useBatchUpdateBusinesses();
const { countries, fetching: fetchingCountries } = useAllCountries();
const { selectableTags, fetching: fetchingTags } = useGetTags();
const { selectableTaxCategories, fetching: fetchingTaxCategories } = useGetTaxCategories();

const fields = buildFields(form);
const isFormEmpty = Object.keys(fields).length === 0;
// sortCode/irsCode map to GraphQL Int, so only whole non-negative integers are valid — reject
// decimals and scientific notation that Number() would otherwise coerce.
// irsCode maps to GraphQL Int, so only whole non-negative integers are valid — reject decimals
// and scientific notation that Number() would otherwise coerce. sortCode comes from a picker.
const hasInvalidNumericFields =
(form.sortCode.trim() !== '' && !INTEGER_PATTERN.test(form.sortCode.trim())) ||
(form.irsCode.trim() !== '' && !INTEGER_PATTERN.test(form.irsCode.trim()));
form.irsCode.trim() !== '' && !INTEGER_PATTERN.test(form.irsCode.trim());

// Single close path so both the Dialog's own dismissals (overlay/esc/X) and the Cancel button
// clear the form before closing.
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
setForm(EMPTY_FORM);
}
onOpenChange(nextOpen);
};

const onSubmit = async (): Promise<void> => {
if (Object.keys(fields).length === 0) {
if (isFormEmpty || businessIds.length === 0) {
return;
}
const updated = await batchUpdateBusinesses({ businessIds, fields });
if (updated) {
setForm(EMPTY_FORM);
setOpen(false);
onOpenChange(false);
onDone();
}
};

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={businessIds.length === 0}>
Batch update{businessIds.length ? ` (${businessIds.length})` : ''}
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[90vh] flex-col sm:max-w-2xl">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="flex max-h-[90vh] flex-col sm:max-w-2xl"
onClick={event => event.stopPropagation()}
>
<DialogHeader>
<DialogTitle>Batch update {businessIds.length} businesses</DialogTitle>
<DialogTitle>
Batch update {businessIds.length} business{businessIds.length === 1 ? '' : 'es'}
</DialogTitle>
<DialogDescription>
Only the fields you fill in are applied to every selected business.
</DialogDescription>
Expand All @@ -184,29 +191,38 @@ export function BatchUpdateBusinessesDialog({
placeholder="Select country"
/>
</div>
<div className="grid gap-1">
<Label>Sort code</Label>
<SortCodeSelect
ownerId={userContext?.context.adminBusinessId}
value={form.sortCode || null}
onChange={value =>
setForm(prev => ({ ...prev, sortCode: value == null ? '' : value.toString() }))
}
placeholder="Select sort code"
/>
</div>
<div className="grid gap-1">
<Label>Tax category</Label>
<ComboBox
data={selectableTaxCategories}
value={form.taxCategory || null}
onChange={value => setForm(prev => ({ ...prev, taxCategory: value ?? '' }))}
disabled={fetchingTaxCategories}
placeholder="Select tax category"
/>
</div>
{FIELDS.map(field => (
<div key={field.key} className={cn('grid gap-1', field.fullWidth && 'sm:col-span-2')}>
<Label htmlFor={`batch-${field.key}`}>{field.label}</Label>
<Input
id={`batch-${field.key}`}
type={field.numeric ? 'number' : 'text'}
value={form[field.key] as string}
placeholder={field.placeholder}
onChange={event => setForm(prev => ({ ...prev, [field.key]: event.target.value }))}
/>
</div>
))}
<div className="grid gap-1 sm:col-span-2">
<Label>Tags</Label>
<MultiSelect
options={selectableTags}
onValueChange={value => setForm(prev => ({ ...prev, tags: value }))}
defaultValue={form.tags}
placeholder="Select tags"
variant="default"
disabled={fetchingTags}
/>
</div>
{FLAG_FIELDS.map(flag => (
<div key={flag.key} className="grid gap-1">
<Label htmlFor={`batch-${flag.key}`}>{flag.label}</Label>
Expand All @@ -229,7 +245,7 @@ export function BatchUpdateBusinessesDialog({
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
Expand Down
Loading
Loading