From 6e1924bae8b50dbb8460e151c1d9a79553fddb19 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:08:52 +0200 Subject: [PATCH] feat(accounting): VAT-code dropdown in the quote editor (+ reusable VatRateSelect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/admin/VatRateSelect.tsx | 77 +++++++++++++++++++ .../pages/admin/quotes/QuoteEditorPage.tsx | 14 +++- frontend/src/services/quotes.service.ts | 2 + frontend/src/services/vatCodes.service.ts | 21 +++++ 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/VatRateSelect.tsx create mode 100644 frontend/src/services/vatCodes.service.ts diff --git a/frontend/src/components/admin/VatRateSelect.tsx b/frontend/src/components/admin/VatRateSelect.tsx new file mode 100644 index 00000000..bac73c34 --- /dev/null +++ b/frontend/src/components/admin/VatRateSelect.tsx @@ -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 = ({ 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 ( +
+ {label && ( + + )} + + {isCustom && ( + onChange(Number(e.target.value) || 0, null)} + /> + )} +
+ ); +}; diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx index 444913d1..a9ee3f2d 100644 --- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx @@ -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 }))} />
- setForm((f) => ({ ...f, vatRate: Number(e.target.value) }))} /> + setForm((f) => ({ ...f, vatRate: rate, vatCode: code }))} /> setForm((f) => ({ ...f, shippingAmount: Number(e.target.value) }))} /> diff --git a/frontend/src/services/quotes.service.ts b/frontend/src/services/quotes.service.ts index 963a2596..9b9f97a6 100644 --- a/frontend/src/services/quotes.service.ts +++ b/frontend/src/services/quotes.service.ts @@ -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; diff --git a/frontend/src/services/vatCodes.service.ts b/frontend/src/services/vatCodes.service.ts new file mode 100644 index 00000000..a4d739dc --- /dev/null +++ b/frontend/src/services/vatCodes.service.ts @@ -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 { + const { data } = await api.get('/admin/vat-codes', { params: { direction: 'output' } }); + return (data?.items ?? []) as VatCodeOption[]; + }, +};