Due date now derives from (scheduled send date else issue date) plus the selected Net-days template, both in the editor and on save. The bill editor renders it read-only with an Override toggle for manual entry; existing invoices preserve their stored due date. Backend adds a single resolveNetDays resolver that honors the split payment-net-days template (previously only the legacy FK was read) and the crm_payment_default_net_days setting, used by createInvoice and the installment-spawn path alike.
738 lines
37 KiB
TypeScript
738 lines
37 KiB
TypeScript
/**
|
|
* Invoice editor — create (manual) or edit. Most invoices come from
|
|
* quote conversion; this page is for one-off / ad-hoc invoicing.
|
|
* Smaller surface than the quote editor on purpose.
|
|
*/
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react';
|
|
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
|
|
import { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service';
|
|
import { quotesService } from '../../../services/quotes.service';
|
|
import { contractsService } from '../../../services/contracts.service';
|
|
import { businessProfileService } from '../../../services/businessProfile.service';
|
|
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
|
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
|
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
|
import { customerAdminService } from '../../../services/customerAdmin.service';
|
|
import { userManagementService } from '../../../services/userManagement.service';
|
|
import { settingsService } from '../../../services/settings.service';
|
|
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
|
|
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
|
import { toast } from 'react-toastify';
|
|
|
|
function toMinor(amount: number) {
|
|
return Math.round((Number(amount) || 0) * 100);
|
|
}
|
|
|
|
export const BillEditorPage: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { timeFormat } = useLocalizedDate();
|
|
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
|
const { id } = useParams<{ id?: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const isEdit = id && id !== 'new';
|
|
|
|
const [customerId, setCustomerId] = useState<number | null>(null);
|
|
const [customerLabel, setCustomerLabel] = useState('');
|
|
// Tracks whether the selected customer is a passive (admin-only)
|
|
// account so the editor can render the "Passive — admin only"
|
|
// badge next to the label. Driven by the same isPassive boolean
|
|
// the backend exposes on transformInvoice.customer + transformQuote.customer.
|
|
const [customerIsPassive, setCustomerIsPassive] = useState(false);
|
|
// Customer search + inline-create state moved into <CustomerPicker> (C.5).
|
|
const [currency, setCurrency] = useState('CHF');
|
|
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
|
|
const [dueDate, setDueDate] = useState('');
|
|
// Due date is normally view-only: it auto-tracks (send date else issue
|
|
// date) + the selected Net-days template, so the payment clock starts
|
|
// on the day the invoice actually goes out. Flipping this lets the
|
|
// admin type a different date by hand; we keep it pinned so the auto
|
|
// effect below stops clobbering their value.
|
|
const [dueDateOverridden, setDueDateOverridden] = useState(false);
|
|
const [scheduledSendAt, setScheduledSendAt] = useState('');
|
|
// null = inherit profile default at render time. 'none' / 'swiss' /
|
|
// 'epc' = explicit per-invoice override. (Existing invoices that
|
|
// already have a value carry it through `setQrFormat` in the load
|
|
// effect; new invoices start as null so they pick up profile.)
|
|
const [qrFormat, setQrFormat] = useState<InvoiceQrFormat | null>(null);
|
|
const [vatRate, setVatRate] = useState(0);
|
|
const [shipping, setShipping] = useState(0);
|
|
const [ccPdfEmail, setCcPdfEmail] = useState('');
|
|
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
|
|
const [paymentTermTemplateId, setPaymentTermTemplateId] = useState<number | null>(null);
|
|
// Migration 124 — split payment-term picker. Net days + Timing are
|
|
// chosen independently; the backend composes the merged snapshot.
|
|
const [paymentNetDaysTemplateId, setPaymentNetDaysTemplateId] = useState<number | null>(null);
|
|
const [paymentTimingTemplateId, setPaymentTimingTemplateId] = useState<number | null>(null);
|
|
// null = use the business-profile default for the chosen currency.
|
|
// A number = explicit per-invoice override pointing at a specific
|
|
// business_bank_accounts row. The backend's
|
|
// resolveBankAccountForCurrency() already prefers this over the
|
|
// currency-default when set, so we just need to surface a picker.
|
|
const [businessBankAccountId, setBusinessBankAccountId] = useState<number | null>(null);
|
|
// Migration 126 — per-invoice Skonto opt-out. Default false so new
|
|
// invoices inherit whatever the template / global default offers.
|
|
const [skontoDisabled, setSkontoDisabled] = useState(false);
|
|
// Ad-hoc installments (commit #6). null = single-invoice mode.
|
|
// Array = the plan; backend spawns N invoices on save when length≥2.
|
|
const [installments, setInstallments] = useState<import('../../../services/quotes.service').PaymentTermInstallment[] | null>(null);
|
|
const [installmentsValid, setInstallmentsValid] = useState(true);
|
|
// Inline event snapshot (migration 123). Mirrors the quote editor's
|
|
// event section — admin can type a free-text label without needing
|
|
// an actual events row, and it carries through to the customer
|
|
// portal + tax report + email templates.
|
|
const [eventName, setEventName] = useState('');
|
|
const [eventDate, setEventDate] = useState('');
|
|
const [eventTimeStart, setEventTimeStart] = useState('');
|
|
const [eventTimeEnd, setEventTimeEnd] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
// Load every configured bank account so the override dropdown can
|
|
// offer them all. We filter by currency in the dropdown itself
|
|
// (with an "any currency" fallback in case the admin wants to be
|
|
// explicit about a cross-currency bank).
|
|
const { data: bankAccountsData } = useQuery({
|
|
queryKey: ['business-bank-accounts'],
|
|
queryFn: () => businessProfileService.listBankAccounts(),
|
|
});
|
|
const bankAccounts = bankAccountsData?.bankAccounts || [];
|
|
|
|
// Migration 124 split the legacy payment-term template into Net days
|
|
// + Timing. The editor now drives entirely off the two split lists;
|
|
// the legacy `ptTemplates` query was kept around "for back-compat
|
|
// preview text" but never read. Removed (D.1 cleanup).
|
|
const { data: netDaysTemplates } = useQuery({
|
|
queryKey: ['payment-net-days-templates'],
|
|
queryFn: () => quotesService.listPaymentNetDaysTemplates(),
|
|
});
|
|
const { data: timingTemplates } = useQuery({
|
|
queryKey: ['payment-timing-templates'],
|
|
queryFn: () => quotesService.listPaymentTimingTemplates(),
|
|
});
|
|
|
|
const { data: existing, isLoading } = useQuery({
|
|
queryKey: ['invoice', id],
|
|
queryFn: () => billsService.get(parseInt(id!, 10)),
|
|
enabled: !!isEdit,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (existing) {
|
|
const inv = existing.invoice;
|
|
setCustomerId(inv.customerAccountId);
|
|
setCustomerLabel(inv.customer.companyName || inv.customer.displayName || inv.customer.email || '');
|
|
setCustomerIsPassive(Boolean(inv.customer.isPassive));
|
|
setCurrency(inv.currency);
|
|
setIssueDate(inv.issueDate);
|
|
setDueDate(inv.dueDate);
|
|
// The invoice already carries a due date — preserve it rather than
|
|
// letting the auto effect recompute and surprise the admin. They
|
|
// can untick "Override" to re-enable auto-tracking.
|
|
setDueDateOverridden(true);
|
|
setScheduledSendAt(inv.scheduledSendAt ? inv.scheduledSendAt.slice(0, 16) : '');
|
|
// Preserve null when the saved invoice has no explicit format —
|
|
// it inherits the profile default at render time.
|
|
setQrFormat((inv.qrFormat as InvoiceQrFormat | null) || null);
|
|
setVatRate(Number(inv.vatRate || 0));
|
|
setShipping(Number(inv.shippingAmountMinor || 0) / 100);
|
|
setCcPdfEmail(inv.ccPdfEmail || '');
|
|
setPaymentTermTemplateId(inv.paymentTermTemplateId ?? null);
|
|
setPaymentNetDaysTemplateId(inv.paymentNetDaysTemplateId ?? null);
|
|
setPaymentTimingTemplateId(inv.paymentTimingTemplateId ?? null);
|
|
setBusinessBankAccountId(inv.businessBankAccountId ?? null);
|
|
setSkontoDisabled(Boolean(inv.skontoDisabled));
|
|
setEventName(inv.eventName || '');
|
|
setEventDate(inv.eventDate || '');
|
|
setEventTimeStart(inv.eventTimeStart || '');
|
|
setEventTimeEnd(inv.eventTimeEnd || '');
|
|
setLineItems(existing.lineItems.map((li) => ({
|
|
id: li.id,
|
|
position: li.position,
|
|
quantity: Number(li.quantity),
|
|
description: li.description,
|
|
unitPrice: Number(li.unitPriceMinor || 0) / 100,
|
|
discountPercent: Number(li.discountPercent || 0),
|
|
parentPosition: li.parentPosition ?? null,
|
|
detailsText: li.detailsText || '',
|
|
})));
|
|
}
|
|
}, [existing]);
|
|
|
|
// Admin auth + list — used to pre-fill + offer a dropdown for the
|
|
// "CC PDF to" field (mirrors the QuoteEditorPage pattern + the
|
|
// admin_email picker on CreateEventPage). Falls back to an empty
|
|
// list silently if the current user lacks users.view permission so
|
|
// basic admins still get the auto-prefill from currentAdmin.
|
|
const { user: currentAdmin } = useAdminAuth();
|
|
const { data: adminUsers } = useQuery({
|
|
queryKey: ['admin-users-list'],
|
|
queryFn: async () => {
|
|
try { return await userManagementService.getUsers(); } catch { return []; }
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
retry: false,
|
|
});
|
|
const activeAdmins = useMemo(
|
|
() => (adminUsers || []).filter((u: any) => u.isActive !== false && !!u.email),
|
|
[adminUsers]
|
|
);
|
|
|
|
// Pre-fill CC PDF email with the current admin's email on a brand-
|
|
// new invoice. Skip on edit and don't clobber existing values.
|
|
const didPrefillCcRef = useRef(false);
|
|
useEffect(() => {
|
|
if (isEdit) return;
|
|
if (didPrefillCcRef.current) return;
|
|
if (!currentAdmin?.email) return;
|
|
didPrefillCcRef.current = true;
|
|
setCcPdfEmail((cur) => cur || currentAdmin.email);
|
|
}, [currentAdmin?.email, isEdit]);
|
|
|
|
// Pre-fill the customer when the editor is opened from a customer
|
|
// detail page via `?customerAccountId=42`. Runs once on mount, only
|
|
// when creating a new invoice, and skips if the user has already
|
|
// picked a customer.
|
|
const didPrefillCustomerRef = useRef(false);
|
|
useEffect(() => {
|
|
if (isEdit) return;
|
|
if (didPrefillCustomerRef.current) return;
|
|
const raw = searchParams.get('customerAccountId');
|
|
const cid = raw ? parseInt(raw, 10) : NaN;
|
|
if (!Number.isFinite(cid) || cid <= 0) return;
|
|
didPrefillCustomerRef.current = true;
|
|
(async () => {
|
|
try {
|
|
const c = await customerAdminService.get(cid);
|
|
if (!customerId) {
|
|
setCustomerId(c.id);
|
|
setCustomerLabel(c.companyName || c.displayName || c.email);
|
|
setCustomerIsPassive(Boolean(c.isPassive));
|
|
}
|
|
} catch {
|
|
// Silent fail — admin can still pick the customer manually.
|
|
}
|
|
})();
|
|
}, [isEdit, searchParams, customerId]);
|
|
|
|
// Pre-fill from a fully-signed contract when the editor is opened
|
|
// via `?fromContractId=<id>` (the "New invoice" link on
|
|
// ContractDetailPage's header, used after the contract has been
|
|
// converted to event / invoices-only and the admin wants to mint
|
|
// an ad-hoc invoice on top of the scheduled ones).
|
|
//
|
|
// Pulls customer + event snapshot from the contract itself, then
|
|
// the line items + currency + VAT from the source quote (contracts
|
|
// don't carry line items themselves — they're composed of free-text
|
|
// blocks). Standalone contracts (no source quote) still pre-fill
|
|
// the customer + event snapshot; the admin types the line items.
|
|
const didPrefillContractRef = useRef(false);
|
|
useEffect(() => {
|
|
if (isEdit) return;
|
|
if (didPrefillContractRef.current) return;
|
|
const raw = searchParams.get('fromContractId');
|
|
const cid = raw ? parseInt(raw, 10) : NaN;
|
|
if (!Number.isFinite(cid) || cid <= 0) return;
|
|
didPrefillContractRef.current = true;
|
|
(async () => {
|
|
try {
|
|
const { contract } = await contractsService.get(cid);
|
|
if (!customerId) {
|
|
setCustomerId(contract.customerAccountId);
|
|
setCustomerLabel(
|
|
contract.customer.companyName
|
|
|| [contract.customer.firstName, contract.customer.lastName].filter(Boolean).join(' ')
|
|
|| contract.customer.displayName
|
|
|| contract.customer.email
|
|
|| '',
|
|
);
|
|
setCustomerIsPassive(Boolean((contract.customer as any).isPassive));
|
|
}
|
|
// Event snapshot — contracts copy these from the source quote
|
|
// on createFromQuote, then the contract editor can override
|
|
// them. Either way we prefer the contract's own values here.
|
|
if (contract.eventName) setEventName(contract.eventName);
|
|
if (contract.eventDate) setEventDate(contract.eventDate);
|
|
if (contract.eventTimeStart) setEventTimeStart(contract.eventTimeStart);
|
|
if (contract.eventTimeEnd) setEventTimeEnd(contract.eventTimeEnd);
|
|
|
|
if (contract.sourceQuoteId) {
|
|
const { quote, lineItems: quoteLineItems } = await quotesService.get(contract.sourceQuoteId);
|
|
if (quote.currency) setCurrency(quote.currency);
|
|
if (quote.vatRate != null) setVatRate(Number(quote.vatRate));
|
|
if (quote.shippingAmountMinor != null) {
|
|
setShipping(Number(quote.shippingAmountMinor) / 100);
|
|
}
|
|
// Map the quote's line items into the editor's editable
|
|
// shape. ids drop (this is a brand-new invoice; line items
|
|
// get fresh ids on save) but position + parent linkage are
|
|
// preserved so the hierarchy carries through.
|
|
setLineItems((quoteLineItems || []).map((li: any) => ({
|
|
position: li.position,
|
|
quantity: Number(li.quantity),
|
|
description: li.description,
|
|
unitPrice: Number(li.unitPriceMinor || 0) / 100,
|
|
discountPercent: Number(li.discountPercent || 0),
|
|
parentPosition: li.parentPosition ?? null,
|
|
detailsText: li.detailsText || '',
|
|
})));
|
|
}
|
|
} catch {
|
|
// Silent fail — admin can still author the invoice manually.
|
|
}
|
|
})();
|
|
}, [isEdit, searchParams, customerId]);
|
|
|
|
// Customer autocomplete moved into <CustomerPicker> (C.5).
|
|
|
|
// Load the admin-configured CRM defaults (migration 125). The
|
|
// editor's prefill prefers these over the hardcoded fallbacks below
|
|
// so the per-invoice picker is a true override.
|
|
const { data: appSettings } = useQuery({
|
|
queryKey: ['app-settings'],
|
|
queryFn: () => settingsService.getAllSettings(),
|
|
enabled: !isEdit,
|
|
});
|
|
|
|
// Auto-default both pickers on a brand-new invoice. Resolution
|
|
// order: configured setting → Net 30 / first-timing fallback → first.
|
|
const didPrefillPaymentRef = useRef(false);
|
|
useEffect(() => {
|
|
if (isEdit) return;
|
|
if (didPrefillPaymentRef.current) return;
|
|
if (!netDaysTemplates?.templates?.length || !timingTemplates?.templates?.length) return;
|
|
if (appSettings === undefined) return;
|
|
|
|
const settingNetDaysId = appSettings?.crm_invoices_default_payment_net_days_template_id;
|
|
const settingTimingId = appSettings?.crm_invoices_default_payment_timing_template_id;
|
|
const settingNetDays = settingNetDaysId
|
|
? netDaysTemplates.templates.find((t) => t.id === settingNetDaysId)
|
|
: null;
|
|
const settingTiming = settingTimingId
|
|
? timingTemplates.templates.find((t) => t.id === settingTimingId)
|
|
: null;
|
|
|
|
const defaultNetDays = settingNetDays
|
|
|| netDaysTemplates.templates.find((t) => t.netDays === 30)
|
|
|| netDaysTemplates.templates[0];
|
|
const defaultTiming = settingTiming
|
|
|| timingTemplates.templates[0];
|
|
|
|
didPrefillPaymentRef.current = true;
|
|
setPaymentNetDaysTemplateId((prev) => prev ?? defaultNetDays.id);
|
|
setPaymentTimingTemplateId((prev) => prev ?? defaultTiming.id);
|
|
}, [isEdit, netDaysTemplates, timingTemplates, appSettings]);
|
|
|
|
// Auto-track the due date off (scheduled send date else issue date) +
|
|
// the selected Net-days template, mirroring the backend's
|
|
// computeDueDate. The clock starts the day the invoice goes out, so
|
|
// scheduling a future send pushes the due date out with it. Skipped
|
|
// once the admin overrides the field by hand. Date math is in UTC to
|
|
// match the backend (which parses the YYYY-MM-DD base as UTC midnight).
|
|
useEffect(() => {
|
|
if (dueDateOverridden) return;
|
|
const base = (scheduledSendAt ? scheduledSendAt.slice(0, 10) : issueDate) || '';
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(base)) return;
|
|
const tpl = netDaysTemplates?.templates?.find((t) => t.id === paymentNetDaysTemplateId);
|
|
const netDays = tpl?.netDays != null
|
|
? Number(tpl.netDays)
|
|
: Number(appSettings?.crm_payment_default_net_days) || 30;
|
|
const d = new Date(`${base}T00:00:00Z`);
|
|
d.setUTCDate(d.getUTCDate() + netDays);
|
|
setDueDate(d.toISOString().slice(0, 10));
|
|
}, [dueDateOverridden, scheduledSendAt, issueDate, paymentNetDaysTemplateId, netDaysTemplates, appSettings]);
|
|
|
|
const buildPayload = (): InvoiceCreatePayload => ({
|
|
customerAccountId: customerId || 0,
|
|
currency,
|
|
issueDate,
|
|
dueDate: dueDate || undefined,
|
|
scheduledSendAt: scheduledSendAt || undefined,
|
|
// Omit when null so the server inherits the profile default;
|
|
// including null would persist as an explicit "no preference"
|
|
// which is the same outcome but pollutes the column.
|
|
qrFormat: qrFormat || undefined,
|
|
vatRate,
|
|
shippingAmountMinor: toMinor(shipping),
|
|
ccPdfEmail: ccPdfEmail || undefined,
|
|
// Payment-term template id (migration 113). null = no template
|
|
// selected; backend falls back to source-quote snapshot or the
|
|
// global crm_invoices_* defaults.
|
|
paymentTermTemplateId: paymentTermTemplateId ?? undefined,
|
|
// Migration 124 — split picker. Backend engages the new path only
|
|
// when both are present; legacy single-FK is still accepted.
|
|
paymentNetDaysTemplateId: paymentNetDaysTemplateId ?? undefined,
|
|
paymentTimingTemplateId: paymentTimingTemplateId ?? undefined,
|
|
// Per-invoice bank-account override. Sending `null` explicitly
|
|
// (instead of stripping the key with `?? undefined`) ensures the
|
|
// PUT handler can detect "clear this override" — JSON serialises
|
|
// undefined out of the body, so an omitted key would otherwise
|
|
// leave the previously-pinned bank in place. A number pins the
|
|
// chosen account; the renderer reads it from
|
|
// invoices.business_bank_account_id at PDF time, no further
|
|
// lookup needed.
|
|
businessBankAccountId: businessBankAccountId,
|
|
// Per-invoice Skonto opt-out (migration 126). Always send so the
|
|
// PUT handler can clear a previously-set opt-out by unchecking.
|
|
skontoDisabled,
|
|
// Ad-hoc installments (commit #6). When set with ≥2 rows backend
|
|
// spawns N invoices via spawnInstallmentInvoices and returns
|
|
// { invoiceIds: [...] }; single-row or null → single invoice.
|
|
installments: installments || undefined,
|
|
// Inline event snapshot (migration 123). Empty string → undefined
|
|
// so the backend can distinguish "not provided" from a deliberate
|
|
// clear (which the route's `optional({ values: 'falsy' })` already
|
|
// treats identically — falsy values bypass validation entirely).
|
|
eventName: eventName || undefined,
|
|
eventDate: eventDate || undefined,
|
|
eventTimeStart: eventTimeStart || undefined,
|
|
eventTimeEnd: eventTimeEnd || undefined,
|
|
lineItems: lineItems.map((li) => ({
|
|
position: li.position,
|
|
quantity: li.quantity,
|
|
description: li.description,
|
|
unitPriceMinor: toMinor(li.unitPrice),
|
|
discountPercent: li.discountPercent,
|
|
// Migration 119 — sub-items + details survive save → reload.
|
|
parentPosition: li.parentPosition ?? null,
|
|
detailsText: li.detailsText || null,
|
|
})),
|
|
});
|
|
|
|
const handleSave = async (then?: 'preview') => {
|
|
if (!customerId) { toast.error(t('bills.errors.customerRequired', 'Pick a customer first.')); return; }
|
|
// Open the preview tab synchronously so popup blockers don't kill
|
|
// it (they reject any window.open that runs after an `await`).
|
|
const previewWindow = then === 'preview' ? window.open('about:blank', '_blank') : null;
|
|
if (then === 'preview' && !previewWindow) {
|
|
toast.error(t('bills.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const payload = buildPayload();
|
|
const saved = isEdit
|
|
? await billsService.update(parseInt(id!, 10), payload)
|
|
: await billsService.create(payload);
|
|
qc.invalidateQueries({ queryKey: ['invoices'] });
|
|
if (then === 'preview') {
|
|
const url = await billsService.pdfUrl(saved.invoice.id);
|
|
if (previewWindow) previewWindow.location.href = url;
|
|
} else if (saved.invoiceIds && saved.invoiceIds.length > 1) {
|
|
// Multi-installment spawn: backend created N invoices. Toast
|
|
// the count and redirect to the first sibling.
|
|
toast.success(t('bills.savedToastMulti',
|
|
'Created {{n}} invoices.', { n: saved.invoiceIds.length }));
|
|
} else {
|
|
toast.success(t('bills.savedToast', 'Invoice saved.'));
|
|
}
|
|
// Redirect to the first invoice (single case = the only invoice;
|
|
// multi case = first installment, admin navigates to siblings
|
|
// via the lineage card).
|
|
navigate(`/admin/clients/bills/${saved.invoice.id}`);
|
|
} catch (err: any) {
|
|
if (previewWindow) previewWindow.close();
|
|
toast.error(err?.response?.data?.error || err.message || 'Save failed');
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
const handlePreviewUnsaved = async () => {
|
|
if (!customerId) { toast.error(t('bills.errors.customerRequired', 'Pick a customer first.')); return; }
|
|
const previewWindow = window.open('about:blank', '_blank');
|
|
if (!previewWindow) {
|
|
toast.error(t('bills.errors.popupBlocked', 'Allow pop-ups for this site to preview the PDF.'));
|
|
return;
|
|
}
|
|
try {
|
|
const url = await billsService.previewPdfUrl(buildPayload());
|
|
previewWindow.location.href = url;
|
|
} catch (err: any) {
|
|
previewWindow.close();
|
|
toast.error(err?.response?.data?.error || 'Preview failed');
|
|
}
|
|
};
|
|
|
|
if (isEdit && isLoading) return <Loading />;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<button onClick={() => navigate('/admin/clients/bills')}
|
|
className="text-sm text-neutral-600 dark:text-neutral-400 hover:underline mb-1 inline-flex items-center gap-1">
|
|
<ArrowLeft className="w-4 h-4" /> {t('common.back', 'Back')}
|
|
</button>
|
|
<h2 className="text-xl font-bold">{isEdit ? `${t('bills.edit', 'Edit invoice')} ${existing?.invoice.invoiceNumber || ''}` : t('bills.new', 'New invoice')}</h2>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={handlePreviewUnsaved} disabled={busy}>
|
|
<Eye className="w-4 h-4 mr-1" />{t('common.preview', 'Preview')}
|
|
</Button>
|
|
<Button onClick={() => handleSave()} disabled={busy || !installmentsValid}>
|
|
<SaveIcon className="w-4 h-4 mr-1" />
|
|
{installments && installments.length > 1
|
|
? t('bills.saveAndSpawn', 'Create {{n}} invoices', { n: installments.length })
|
|
: t('common.save', 'Save')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<h3 className="font-semibold mb-2">{t('bills.section.customer', 'Customer')}</h3>
|
|
<CustomerPicker
|
|
value={customerId}
|
|
label={customerLabel}
|
|
isPassive={customerIsPassive}
|
|
onSelect={(c) => {
|
|
setCustomerId(c.id);
|
|
setCustomerLabel(c.companyName || c.displayName || c.email);
|
|
setCustomerIsPassive(Boolean(c.isPassive));
|
|
}}
|
|
onCreate={(c) => {
|
|
setCustomerId(c.id);
|
|
setCustomerLabel(c.companyName || c.displayName || c.email);
|
|
setCustomerIsPassive(Boolean(c.isPassive));
|
|
}}
|
|
onClear={() => {
|
|
setCustomerId(null);
|
|
setCustomerLabel('');
|
|
setCustomerIsPassive(false);
|
|
}}
|
|
searchPlaceholder={t('bills.customerSearch', 'Search by email or company…') as string}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Event snapshot section (migration 123). Mirrors the quote
|
|
editor's Event section — free-text label that doesn't require
|
|
a linked events row. The values flow into the invoice email
|
|
template's {{event_name}} placeholder, the customer portal
|
|
row heading, the admin list "Event" column, and the tax
|
|
report event column. */}
|
|
<Card>
|
|
<h3 className="font-semibold mb-2">{t('bills.section.event', 'Event details')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<Input label={t('bills.field.eventName', 'Event') as string}
|
|
value={eventName} onChange={(e) => setEventName(e.target.value)} />
|
|
<LocalizedDateInput label={t('bills.field.eventDate', 'Event date') as string}
|
|
value={eventDate} onChange={setEventDate} />
|
|
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string}
|
|
value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
|
|
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string}
|
|
value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<h3 className="font-semibold mb-2">{t('bills.section.details', 'Details')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<LocalizedDateInput label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={setIssueDate} />
|
|
<div>
|
|
<LocalizedDateInput
|
|
label={t('bills.field.dueDate', 'Due date') as string}
|
|
value={dueDate}
|
|
onChange={setDueDate}
|
|
disabled={!dueDateOverridden}
|
|
/>
|
|
<label className="mt-1.5 flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
|
|
<input
|
|
type="checkbox"
|
|
checked={dueDateOverridden}
|
|
onChange={(e) => setDueDateOverridden(e.target.checked)}
|
|
className="rounded border-neutral-300 dark:border-neutral-600"
|
|
/>
|
|
{dueDateOverridden
|
|
? t('bills.field.dueDateOverrideOn', 'Manual due date — untick to auto-set from send date + payment term')
|
|
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
|
|
</label>
|
|
</div>
|
|
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
|
|
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t('bills.field.qrFormat', 'Payment QR format')}</label>
|
|
<select
|
|
value={qrFormat || ''}
|
|
onChange={(e) => setQrFormat((e.target.value || null) as any)}
|
|
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
|
|
{/* Empty value = use the business-profile default. Server
|
|
resolves the actual format at render time, so admins
|
|
who curate it once in Settings → Business profile
|
|
never have to think about it per-invoice. */}
|
|
<option value="">{t('bills.qrFormat.profileDefault', 'Use business profile default')}</option>
|
|
<option value="none">{t('bills.qrFormat.none', 'None (override)')}</option>
|
|
<option value="swiss">{t('bills.qrFormat.swiss', 'Swiss QR-bill')}</option>
|
|
<option value="epc">{t('bills.qrFormat.epc', 'EPC QR (SEPA)')}</option>
|
|
</select>
|
|
</div>
|
|
{/* Per-invoice bank-account override (migration 102 column
|
|
business_bank_account_id). Empty value = let the server
|
|
pick the default account for the invoice currency at
|
|
save time. Showing every configured bank lets the admin
|
|
pin one for this invoice without changing the global
|
|
defaults in Settings → Business profile. */}
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium mb-1">
|
|
{t('bills.field.businessBankAccount', 'Payment account')}
|
|
</label>
|
|
<select
|
|
value={businessBankAccountId == null ? '' : String(businessBankAccountId)}
|
|
onChange={(e) => setBusinessBankAccountId(e.target.value ? Number(e.target.value) : null)}
|
|
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
|
|
<option value="">
|
|
{t('bills.field.bankAccountProfileDefault',
|
|
'Use business profile default for {{currency}}', { currency })}
|
|
</option>
|
|
{bankAccounts.map((b) => {
|
|
const labelParts = [
|
|
b.label || b.accountHolder || b.iban,
|
|
b.currency,
|
|
b.isDefault ? t('bills.field.bankAccountDefaultBadge', '(default)') : null,
|
|
].filter(Boolean);
|
|
return (
|
|
<option key={b.id} value={b.id}>
|
|
{labelParts.join(' · ')} — {b.iban}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
<p className="text-xs text-neutral-500 mt-1">
|
|
{t('bills.field.bankAccountHelp',
|
|
'Overrides the profile default for this invoice only. Leave on default to inherit the currency-matched account from Settings → Business profile.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<h3 className="font-semibold mb-2">{t('bills.section.lineItems', 'Line items')}</h3>
|
|
<LineItemsTable items={lineItems} currency={currency} showDiscount={false}
|
|
vatRate={vatRate / 100} shippingAmount={shipping} onChange={setLineItems} />
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
|
<Input type="number" step="0.1" label={t('bills.field.vatRate', 'VAT rate %') as string}
|
|
value={vatRate} onChange={(e) => setVatRate(Number(e.target.value))} />
|
|
<Input type="number" step="0.01" label={t('bills.field.shipping', 'Shipping') as string}
|
|
value={shipping} onChange={(e) => setShipping(Number(e.target.value))} />
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t('bills.field.currency', 'Currency')}</label>
|
|
<select value={currency} onChange={(e) => setCurrency(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
|
|
<option>CHF</option><option>EUR</option><option>USD</option><option>GBP</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Payment conditions — picks net-days + Skonto from the shared
|
|
payment-term templates (same dropdown the quote editor uses).
|
|
Optional: leave at "— Select —" to let the renderer fall back
|
|
to the source quote's snapshot, or to the global
|
|
crm_invoices_* defaults for ad-hoc invoices. */}
|
|
{/* Migration 124 — split payment-term picker. Two side-by-side
|
|
dropdowns replace the legacy single one. The `ptTemplates`
|
|
query is still mounted above so legacy invoices whose state
|
|
only has the single FK still resolve their preview text. */}
|
|
<Card>
|
|
<h3 className="font-semibold mb-2">{t('bills.section.payment', 'Payment conditions')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t('bills.field.paymentNetDays', 'Net days')}</label>
|
|
<select
|
|
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
|
value={paymentNetDaysTemplateId || ''}
|
|
onChange={(e) => setPaymentNetDaysTemplateId(e.target.value ? Number(e.target.value) : null)}
|
|
>
|
|
<option value="">{t('bills.field.selectNetDays', '— Select net days —')}</option>
|
|
{netDaysTemplates?.templates.map((tpl) => (
|
|
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">{t('bills.field.paymentTiming', 'Payment schedule')}</label>
|
|
<select
|
|
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
|
value={paymentTimingTemplateId || ''}
|
|
onChange={(e) => setPaymentTimingTemplateId(e.target.value ? Number(e.target.value) : null)}
|
|
>
|
|
<option value="">{t('bills.field.selectTiming', '— Select schedule —')}</option>
|
|
{timingTemplates?.templates.map((tpl) => (
|
|
<option key={tpl.id} value={tpl.id}>{tpl.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{/* Per-invoice Skonto opt-out (migration 126). Surfaced
|
|
alongside the payment-term pickers because it's a peer
|
|
override of the same CRM defaults. Admin ticks this for
|
|
invoices that shouldn't qualify even when the global
|
|
default offers Skonto (e.g. Storni, instalments, retainers). */}
|
|
<label className="flex items-start gap-2 text-sm mt-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-1"
|
|
checked={skontoDisabled}
|
|
onChange={(e) => setSkontoDisabled(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t('bills.field.skontoDisabled',
|
|
'Disable Skonto for this invoice (suppresses the early-payment-discount block on the PDF and the "Paid with Skonto" buttons in the admin email / record-payment dialog).')}
|
|
</span>
|
|
</label>
|
|
<p className="text-xs text-neutral-500 mt-2">
|
|
{t('bills.field.paymentTermHelp',
|
|
'Net days + Skonto for this invoice. Leave blank to inherit from the source quote or the global CRM defaults.')}
|
|
</p>
|
|
|
|
{/* Ad-hoc installments panel (commit #6). When the admin builds
|
|
a multi-row plan and clicks Save, the backend spawns one
|
|
invoice per row via spawnInstallmentInvoices (commit #4)
|
|
and returns the array of new ids. */}
|
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
|
<InstallmentsPanel
|
|
value={installments}
|
|
onChange={setInstallments}
|
|
onValidityChange={setInstallmentsValid}
|
|
eventDate={eventDate || null}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
{/* CC PDF — admin email prefilled, with a picker when more than
|
|
one admin exists. Mirrors the quote editor + CreateEventPage
|
|
so the muscle memory carries over. */}
|
|
<div className="space-y-1">
|
|
<Input type="email"
|
|
label={t('bills.field.ccPdfEmail', 'CC PDF to (extra recipient)') as string}
|
|
placeholder={t('bills.field.ccPdfEmailPlaceholder', '[email protected]') as string}
|
|
value={ccPdfEmail} onChange={(e) => setCcPdfEmail(e.target.value)} />
|
|
{activeAdmins.length > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="bill-cc-pdf-picker" className="text-xs text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
|
|
{t('bills.field.ccPdfPickFromAdmins', 'Pick from admins:')}
|
|
</label>
|
|
<select
|
|
id="bill-cc-pdf-picker"
|
|
value={activeAdmins.some((a: any) => a.email === ccPdfEmail) ? ccPdfEmail : ''}
|
|
onChange={(e) => {
|
|
const email = e.target.value;
|
|
if (email) setCcPdfEmail(email);
|
|
}}
|
|
className="text-xs px-2 py-1 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
|
>
|
|
<option value="">{t('bills.field.ccPdfCustom', 'Custom email')}</option>
|
|
{activeAdmins.map((a: any) => (
|
|
<option key={a.id} value={a.email}>{a.email}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|