whisper-money/resources/js/components/transactions/split-editor.tsx

187 lines
6.5 KiB
TypeScript

import { LabelCombobox } from '@/components/shared/label-combobox';
import { CategorySelect } from '@/components/transactions/category-select';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
import { useLocale } from '@/hooks/use-locale';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { __ } from '@/utils/i18n';
import { Plus, X } from 'lucide-react';
export interface SplitLineDraft {
key: string;
category_id: string | null;
amount: number;
label_ids: string[];
}
interface SplitEditorProps {
total: number;
currencyCode: string;
categories: Category[];
labels: Label[];
lines: SplitLineDraft[];
onChange: (lines: SplitLineDraft[]) => void;
disabled?: boolean;
onLabelCreated?: (label: Label) => void;
}
export function newSplitLine(
partial?: Partial<SplitLineDraft>,
): SplitLineDraft {
return {
key: crypto.randomUUID(),
category_id: null,
amount: 0,
label_ids: [],
...partial,
};
}
export function splitRemaining(total: number, lines: SplitLineDraft[]): number {
return total - lines.reduce((sum, line) => sum + line.amount, 0);
}
/**
* A split is valid once it has at least two lines that each carry a non-zero
* amount on the same side as the (non-zero) transaction total and together
* reconcile to it to the cent.
*/
export function isSplitValid(total: number, lines: SplitLineDraft[]): boolean {
if (total === 0 || lines.length < 2 || splitRemaining(total, lines) !== 0) {
return false;
}
return lines.every(
(line) => line.amount !== 0 && line.amount > 0 === total > 0,
);
}
export function SplitEditor({
total,
currencyCode,
categories,
labels,
lines,
onChange,
disabled,
onLabelCreated,
}: SplitEditorProps) {
const locale = useLocale();
const remaining = splitRemaining(total, lines);
const updateLine = (key: string, patch: Partial<SplitLineDraft>) =>
onChange(
lines.map((line) =>
line.key === key ? { ...line, ...patch } : line,
),
);
const removeLine = (key: string) =>
onChange(lines.filter((line) => line.key !== key));
const addLine = () =>
onChange([...lines, newSplitLine({ amount: remaining })]);
const valid = isSplitValid(total, lines);
// Remaining can be 0 while the split is still invalid (e.g. a seeded
// zero-amount line), so key the styling off validity, not just the balance.
const hasZeroLine = lines.some((line) => line.amount === 0);
const formattedRemaining = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(remaining / 100);
return (
<div className="space-y-3" data-testid="split-editor">
{lines.map((line) => (
<div
key={line.key}
className="space-y-2 rounded-md border border-border p-3"
>
<div className="flex items-start gap-2">
<div className="flex-1">
<CategorySelect
value={line.category_id ?? 'null'}
onValueChange={(value) =>
updateLine(line.key, {
category_id:
value === 'null' ||
value === 'uncategorized'
? null
: value,
})
}
categories={categories}
disabled={disabled}
showUncategorized={true}
triggerClassName="w-full"
/>
</div>
<div className="w-32">
<AmountInput
value={line.amount}
onChange={(value) =>
updateLine(line.key, { amount: value })
}
currencyCode={currencyCode}
disabled={disabled}
allowNegative={true}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeLine(line.key)}
disabled={disabled || lines.length <= 2}
aria-label={__('Remove line')}
>
<X className="h-4 w-4" />
</Button>
</div>
<LabelCombobox
value={line.label_ids}
onValueChange={(ids) =>
updateLine(line.key, { label_ids: ids })
}
labels={labels}
disabled={disabled}
placeholder={__('Add labels...')}
allowCreate={true}
onLabelCreated={onLabelCreated}
/>
</div>
))}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
size="sm"
onClick={addLine}
disabled={disabled}
>
<Plus className="mr-1 h-4 w-4" />
{__('Add line')}
</Button>
<span
className={
valid
? 'text-sm text-muted-foreground'
: 'text-sm font-medium text-destructive'
}
data-testid="split-remaining"
>
{remaining === 0 && hasZeroLine
? __('Each line needs a non-zero amount')
: __('Remaining: :amount', {
amount: formattedRemaining,
})}
</span>
</div>
</div>
);
}