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:
@@ -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 { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
||||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||||
|
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
|
||||||
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||||
import { userManagementService } from '../../../services/userManagement.service';
|
import { userManagementService } from '../../../services/userManagement.service';
|
||||||
@@ -54,6 +55,8 @@ interface FormState {
|
|||||||
paymentNetDaysTemplateId: number | null;
|
paymentNetDaysTemplateId: number | null;
|
||||||
paymentTimingTemplateId: number | null;
|
paymentTimingTemplateId: number | null;
|
||||||
vatRate: number;
|
vatRate: number;
|
||||||
|
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
|
||||||
|
vatCode: string | null;
|
||||||
shippingAmount: number;
|
shippingAmount: number;
|
||||||
introText: string;
|
introText: string;
|
||||||
outroText: string;
|
outroText: string;
|
||||||
@@ -85,6 +88,7 @@ const empty: FormState = {
|
|||||||
paymentNetDaysTemplateId: null,
|
paymentNetDaysTemplateId: null,
|
||||||
paymentTimingTemplateId: null,
|
paymentTimingTemplateId: null,
|
||||||
vatRate: 0,
|
vatRate: 0,
|
||||||
|
vatCode: null,
|
||||||
shippingAmount: 0,
|
shippingAmount: 0,
|
||||||
introText: '',
|
introText: '',
|
||||||
outroText: '',
|
outroText: '',
|
||||||
@@ -121,6 +125,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
|||||||
// installments on the snapshot. Sent only when populated.
|
// installments on the snapshot. Sent only when populated.
|
||||||
installments: f.installments && f.installments.length > 0 ? f.installments : undefined,
|
installments: f.installments && f.installments.length > 0 ? f.installments : undefined,
|
||||||
vatRate: f.vatRate,
|
vatRate: f.vatRate,
|
||||||
|
vatCode: f.vatCode,
|
||||||
shippingAmountMinor: toMinor(f.shippingAmount),
|
shippingAmountMinor: toMinor(f.shippingAmount),
|
||||||
introText: f.introText || undefined,
|
introText: f.introText || undefined,
|
||||||
outroText: f.outroText || undefined,
|
outroText: f.outroText || undefined,
|
||||||
@@ -214,6 +219,7 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
paymentNetDaysTemplateId: q.paymentNetDaysTemplateId,
|
paymentNetDaysTemplateId: q.paymentNetDaysTemplateId,
|
||||||
paymentTimingTemplateId: q.paymentTimingTemplateId,
|
paymentTimingTemplateId: q.paymentTimingTemplateId,
|
||||||
vatRate: Number(q.vatRate || 0),
|
vatRate: Number(q.vatRate || 0),
|
||||||
|
vatCode: (q as { vatCode?: string | null }).vatCode ?? null,
|
||||||
shippingAmount: Number(q.shippingAmountMinor || 0) / 100,
|
shippingAmount: Number(q.shippingAmountMinor || 0) / 100,
|
||||||
introText: q.introText || '',
|
introText: q.introText || '',
|
||||||
outroText: q.outroText || '',
|
outroText: q.outroText || '',
|
||||||
@@ -533,9 +539,11 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
|
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
<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}
|
<VatRateSelect
|
||||||
value={form.vatRate}
|
label={t('quotes.field.vatRate', 'VAT rate %') as string}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, vatRate: Number(e.target.value) }))} />
|
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}
|
<Input type="number" step="0.01" label={t('quotes.field.shipping', 'Shipping amount') as string}
|
||||||
value={form.shippingAmount}
|
value={form.shippingAmount}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, shippingAmount: Number(e.target.value) }))} />
|
onChange={(e) => setForm((f) => ({ ...f, shippingAmount: Number(e.target.value) }))} />
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ export interface QuoteCreatePayload {
|
|||||||
* the template's value as-is. */
|
* the template's value as-is. */
|
||||||
installments?: PaymentTermInstallment[];
|
installments?: PaymentTermInstallment[];
|
||||||
vatRate?: number;
|
vatRate?: number;
|
||||||
|
/** Migration 130 — snapshot of the chosen output VAT code (null = custom rate). */
|
||||||
|
vatCode?: string | null;
|
||||||
shippingAmountMinor?: number;
|
shippingAmountMinor?: number;
|
||||||
introText?: string;
|
introText?: string;
|
||||||
outroText?: string;
|
outroText?: string;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user