feat(accounting): VAT-code dropdown in the quote editor (+ reusable VatRateSelect)

Slice 3a — replaces the free-typed VAT rate in the quote editor with a dropdown
of configured output VAT codes (+ 'Other (custom rate)'), reading the un-gated
/admin/vat-codes endpoint. Selecting a code sends vatCode → the backend snapshots
it (migration 130) and the export emits it. New VatRateSelect component + a
read-only vatCodes.service. Create flow snapshots correctly; loading a saved code
into the editor (serialization return) + the bill editor are the next slices.
Build green.
This commit is contained in:
Luca
2026-06-16 00:08:52 +02:00
parent fbbbb8ab73
commit 6e1924bae8
4 changed files with 111 additions and 3 deletions
@@ -0,0 +1,77 @@
/**
* VAT-rate picker for the invoice/quote editors. A dropdown of the configured
* OUTPUT VAT codes (Settings → Accounting) plus an "Other (custom rate)" escape
* hatch. Controlled by `(rate, code)`: selecting a code emits its rate + code
* string (snapshotted on the document for the accounting export); "Other" emits
* the typed rate with a null code. Reads the un-gated /admin/vat-codes endpoint,
* so it works even when the accounting feature is off.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { vatCodesService, type VatCodeOption } from '../../services/vatCodes.service';
const CUSTOM = '__custom__';
const selectCls =
'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500';
interface Props {
rate: number;
code: string | null;
onChange: (rate: number, code: string | null) => void;
label?: string;
disabled?: boolean;
}
export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, disabled }) => {
const { t } = useTranslation();
const { data: codes = [] } = useQuery({
queryKey: ['vat-codes', 'output'],
queryFn: () => vatCodesService.listOutput(),
staleTime: 5 * 60 * 1000,
});
// Selected option: prefer the snapshotted code; else a code whose rate matches
// (legacy rows / no code stored); else "custom".
const matched: VatCodeOption | undefined =
(code ? codes.find((c) => c.code === code) : undefined)
|| (!code ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
const isCustom = !matched;
return (
<div>
{label && (
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{label}</label>
)}
<select
className={selectCls}
disabled={disabled}
value={isCustom ? CUSTOM : String(matched!.id)}
onChange={(e) => {
if (e.target.value === CUSTOM) { onChange(rate, null); return; }
const c = codes.find((x) => String(x.id) === e.target.value);
if (c) onChange(Number(c.rate), c.code);
}}
>
{codes.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name} ({Number(c.rate).toFixed(1)}%)
</option>
))}
<option value={CUSTOM}>{t('vat.customRate', 'Other (custom rate)')}</option>
</select>
{isCustom && (
<input
type="number"
step="0.1"
min="0"
className={`${selectCls} mt-2`}
disabled={disabled}
value={rate}
placeholder={t('vat.ratePercent', 'VAT rate %') as string}
onChange={(e) => onChange(Number(e.target.value) || 0, null)}
/>
)}
</div>
);
};
@@ -25,6 +25,7 @@ import {
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
@@ -54,6 +55,8 @@ interface FormState {
paymentNetDaysTemplateId: number | null;
paymentTimingTemplateId: number | null;
vatRate: number;
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
vatCode: string | null;
shippingAmount: number;
introText: string;
outroText: string;
@@ -85,6 +88,7 @@ const empty: FormState = {
paymentNetDaysTemplateId: null,
paymentTimingTemplateId: null,
vatRate: 0,
vatCode: null,
shippingAmount: 0,
introText: '',
outroText: '',
@@ -121,6 +125,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
// installments on the snapshot. Sent only when populated.
installments: f.installments && f.installments.length > 0 ? f.installments : undefined,
vatRate: f.vatRate,
vatCode: f.vatCode,
shippingAmountMinor: toMinor(f.shippingAmount),
introText: f.introText || undefined,
outroText: f.outroText || undefined,
@@ -214,6 +219,7 @@ export const QuoteEditorPage: React.FC = () => {
paymentNetDaysTemplateId: q.paymentNetDaysTemplateId,
paymentTimingTemplateId: q.paymentTimingTemplateId,
vatRate: Number(q.vatRate || 0),
vatCode: (q as { vatCode?: string | null }).vatCode ?? null,
shippingAmount: Number(q.shippingAmountMinor || 0) / 100,
introText: q.introText || '',
outroText: q.outroText || '',
@@ -533,9 +539,11 @@ export const QuoteEditorPage: React.FC = () => {
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
<Input type="number" step="0.1" label={t('quotes.field.vatRate', 'VAT rate %') as string}
value={form.vatRate}
onChange={(e) => setForm((f) => ({ ...f, vatRate: Number(e.target.value) }))} />
<VatRateSelect
label={t('quotes.field.vatRate', 'VAT rate %') as string}
rate={form.vatRate}
code={form.vatCode}
onChange={(rate, code) => setForm((f) => ({ ...f, vatRate: rate, vatCode: code }))} />
<Input type="number" step="0.01" label={t('quotes.field.shipping', 'Shipping amount') as string}
value={form.shippingAmount}
onChange={(e) => setForm((f) => ({ ...f, shippingAmount: Number(e.target.value) }))} />
+2
View File
@@ -191,6 +191,8 @@ export interface QuoteCreatePayload {
* the template's value as-is. */
installments?: PaymentTermInstallment[];
vatRate?: number;
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
vatCode?: string | null;
shippingAmountMinor?: number;
introText?: string;
outroText?: string;
+21
View File
@@ -0,0 +1,21 @@
/**
* Read-only VAT-code registry for the invoice/quote editors. Hits the un-gated
* /admin/vat-codes endpoint (works without the accounting feature). Management
* lives in Settings → Accounting (ledger.service).
*/
import { api } from '../config/api';
export interface VatCodeOption {
id: number;
code: string;
name: string;
rate: number;
direction: 'output' | 'input';
}
export const vatCodesService = {
async listOutput(): Promise<VatCodeOption[]> {
const { data } = await api.get('/admin/vat-codes', { params: { direction: 'output' } });
return (data?.items ?? []) as VatCodeOption[];
},
};