feat(crm): frontend code — pages + services + components

Brings in the full frontend CRM stack: admin authoring pages,
customer-portal surfaces, public response flows, typed services,
and the supporting component library. i18n locale JSON is the next
commit (kept separate so reviewers can read it as data).

Pages
  - Quotes: list / editor / detail / public accept-decline
  - Invoices: list / editor / detail / public payment-check
  - Contracts: list / editor / detail / block library / public sign
  - Calendar (FullCalendar — admin-only v1)
  - Tax report (period picker + CSV/PDF export)
  - Hours (logged time entries, per-customer)
  - Deals lineage (DocumentLineageCard surfaces)
  - CRM Development (admin dev tools, gated by crmDevelopment flag)
  - Customer-portal pages for quotes / invoices / contracts
  - Settings reorg: CRM-Settings group + dedicated tabs for Business
    Profile, CRM behaviour, Contracts block library, Reminder emails
  - BrandingPage typography (PDF font picker)
  - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions
    (event-time fields, hours toggle, per-event reminder override)

Services (typed)
  - quotes.service, bills.service, contracts.service
  - customerAdmin.service, deals.service, calendar.service,
    taxReport.service, contracts-blocks.service
  - businessProfile.service (timezone, font picker, bank accounts)
  - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension

Components (admin)
  - CustomerPicker (shared across quote/invoice/contract editors)
  - LineItemsTable (hierarchy + details_text, memoised pricing)
  - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger)
  - DocumentLineageCard (deal_uuid grouped view)
  - EditInstallmentPlanModal (atomic post-spawn plan reshape)
  - EventReminderOverrideCard, EmailTemplateEditor (tiptap),
    PdfFontPicker, IntegrityCheckCard
  - Feature-flag context + RequireFeature wrapper + AdminSidebar
    featureFlagsAny derivation + UI-hiding sweep

Build infra
  - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded)
  - frontend/package.json: tiptap, fullcalendar, signature_pad,
    react-international-phone, et al.
  - tailwind + prose styles updated for editor surfaces

3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved
to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern
over feat/crm's hardcoded option list; feat/crm's DecimalInput
import preserved alongside.
This commit is contained in:
Luca
2026-05-26 18:19:32 +02:00
parent d543949188
commit a7e16e7bf6
96 changed files with 18370 additions and 572 deletions
+388
View File
@@ -0,0 +1,388 @@
/**
* Admin → Invoices API client. Hits /api/admin/invoices/*.
*/
import { api } from '../config/api';
import type { QuoteLineItem } from './quotes.service';
// `'skipped'` is the post-fact status of a monthly draft that aged out
// of its period with zero line items (migration 128 monthly pass).
// Carried in the row for audit-trail continuity; never visible to the
// customer, never counted in tax/dashboard aggregates.
export type InvoiceStatus = 'scheduled' | 'sent' | 'paid' | 'overdue' | 'cancelled' | 'pending_delivery' | 'skipped';
/**
* Document discriminator. `'invoice'` is the default; `'storno'` rows
* are Stornorechnungen (cancellation invoices) generated by the
* cancel/reissue flow. Both share the table + sequence, distinguished
* here so the UI can render the correct title/badge and gate actions
* (a Storno can't be cancelled, paid, or reissued).
*/
export type InvoiceKind = 'invoice' | 'storno';
export type InvoiceSort =
| 'newest' | 'oldest'
| 'due_asc' | 'due_desc'
| 'value_asc' | 'value_desc'
| 'customer_asc';
export type InvoiceQrFormat = 'swiss' | 'epc' | 'none';
export interface InvoiceSummary {
id: number;
/** 'invoice' (default) or 'storno' (cancellation document). Drives
* badge rendering + action gating in the admin list and detail
* views. */
kind: InvoiceKind;
invoiceNumber: string;
/** Cross-document lineage UUID (migration 140). See the equivalent
* field on QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
customer: {
email: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
companyName: string | null;
/** Computed server-side from password_hash. true = admin-only
* customer (no portal access). Drives the Passive badge that
* appears next to the customer label across editor + lists. */
isPassive?: boolean;
};
sourceQuoteId: number | null;
/** Migration 130 — set by contractService.convertToInvoiceOnly when
* the invoice originates from a contract. */
sourceContractId?: number | null;
/** Human-readable quote number of the source quote (joined-in on read).
* Surfaced on the invoice detail view so the link reads
* "From quote LBM-Q-2026-0006" rather than "From quote #6". */
sourceQuoteNumber?: string | null;
/** Human-readable contract number of the source contract (joined-in
* on read). Surfaced on the invoice detail view so the link reads
* "From contract LBM-C-2026-0010" rather than "From contract #10". */
sourceContractNumber?: string | null;
eventId: number | null;
/** Inline event snapshot (migration 123). Free-text label for the
* event the invoice relates to. Persists independently of the
* events FK so renames don't retroactively change historical
* documents. Shown next to the invoice number on the customer
* portal and as the "Event" column on the admin list. */
eventName: string | null;
/** Event date snapshot. */
eventDate: string | null;
/** Event start time, "HH:MM". */
eventTimeStart: string | null;
/** Event end time, "HH:MM". */
eventTimeEnd: string | null;
language: string;
currency: string;
issueDate: string;
dueDate: string;
installmentIndex: number;
installmentTotal: number;
installmentLabel: string | null;
installmentTrigger: string | null;
status: InvoiceStatus;
scheduledSendAt: string | null;
sentAt: string | null;
totalAmountMinor: number;
paidAmountMinor: number;
reminderLevel: number;
lateFeeAmountMinor: number;
/** True when the invoice was attached from a historical PDF
* (migration 111). Hide line-item editing on these rows; the
* uploaded PDF is the source of truth. */
isImported?: boolean;
}
export interface InvoiceDetail extends InvoiceSummary {
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
paidAt: string | null;
paymentMethod: string | null;
paymentReference: string | null;
lastReminderSentAt: string | null;
ccPdfEmail: string | null;
qrFormat: InvoiceQrFormat | null;
pdfPath: string | null;
businessBankAccountId: number | null;
/** Selected payment-term template id (migration 113). When set
* the renderer uses this template's snapshot for the
* Zahlungsbedingungen block; otherwise it falls back to the
* source quote's snapshot or the global crm_invoices_* defaults. */
paymentTermTemplateId?: number | null;
/** Migration 124 — split payment-term picker. The editor prefers
* these over the legacy single FK; null on legacy rows. Both must
* be set together for the new path to engage server-side. */
paymentNetDaysTemplateId?: number | null;
paymentTimingTemplateId?: number | null;
/** Effective Skonto percentage resolved server-side from the
* invoice's payment-term snapshot (with legacy + global fallback).
* null when no Skonto is configured — used by the BillDetail
* "Record payment" dialog to decide whether to show the
* "Paid with Skonto" checkbox. Migration 126. */
skontoPercent?: number | null;
/** Per-invoice Skonto opt-out — admin ticked "Disable Skonto" in
* the editor. When true, `skontoPercent` is suppressed regardless
* of what the snapshot / global default offers. Migration 126. */
skontoDisabled?: boolean;
/** Set when this invoice was created via Cancel & reissue —
* points at the cancelled original. Migration 114. */
replacesInvoiceId?: number | null;
/** On a Storno row (`kind='storno'`): points at the original
* invoice this Storno reverses. Drives the "Storno zu Rechnung
* R-XXXX" reference rendering in the admin detail view. */
cancelsInvoiceId?: number | null;
/** Human invoice_number of the row referenced by `cancelsInvoiceId`
* (joined server-side). Used so the lineage banner can display
* "R-2026-0007" instead of the bare row id "#7". */
cancelsInvoiceNumber?: string | null;
/** On a CANCELLED original: points at the Storno that cancelled
* it. Drives the "Cancelled by Storno S-XXXX" banner shown on
* the cancelled invoice's detail view. */
cancellationStornoId?: number | null;
/** Human invoice_number of the row referenced by
* `cancellationStornoId` (joined server-side). */
cancellationStornoNumber?: string | null;
}
export interface InvoicePayment {
id: number;
amountMinor: number;
paidAt: string;
paymentMethod: string | null;
reference: string | null;
notes: string | null;
createdAt: string;
}
export interface InvoiceWithLineItems {
invoice: InvoiceDetail;
lineItems: QuoteLineItem[];
payments: InvoicePayment[];
/** Migration 140 + commit #4. Always present on create; single
* invoice = one-element array, multi-installment spawn = N. */
invoiceIds?: number[];
}
export interface InvoiceCreatePayload {
customerAccountId: number;
sourceQuoteId?: number;
eventId?: number;
language?: string;
currency?: string;
issueDate?: string;
dueDate?: string;
scheduledSendAt?: string;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string;
installmentTrigger?: string;
vatRate?: number;
shippingAmountMinor?: number;
ccPdfEmail?: string;
// null = explicitly clear the per-invoice override (back to the
// business-profile default for the currency). undefined = no
// change. number = pin this specific bank account.
businessBankAccountId?: number | null;
qrFormat?: InvoiceQrFormat;
paymentTermTemplateId?: number | null;
// Migration 124 — split payment-term picker. Both required by the
// editor in tandem; backend composes the merged snapshot.
paymentNetDaysTemplateId?: number | null;
paymentTimingTemplateId?: number | null;
// Per-invoice Skonto opt-out (migration 126).
skontoDisabled?: boolean;
// Ad-hoc installments (commit #6 of the deal_uuid PR). When set
// with ≥2 rows, backend spawns N invoices via
// spawnInstallmentInvoices and returns invoiceIds[]. Single row or
// omitted → single invoice.
installments?: import('./quotes.service').PaymentTermInstallment[];
// Inline event snapshot (migration 123). All optional — standalone
// invoices may have none of these.
eventName?: string;
eventDate?: string;
eventTimeStart?: string;
eventTimeEnd?: string;
lineItems: QuoteLineItem[];
}
export interface InvoiceListResponse {
invoices: InvoiceSummary[];
pagination: { total: number; page: number; pageSize: number; totalPages: number };
}
export const billsService = {
async list(params: {
status?: InvoiceStatus[];
customerAccountId?: number;
sourceQuoteId?: number;
unpaidOnly?: boolean;
q?: string;
sort?: InvoiceSort;
page?: number;
pageSize?: number;
} = {}): Promise<InvoiceListResponse> {
const { data } = await api.get('/admin/invoices', {
params: {
...params,
status: params.status?.join(','),
},
});
return data.data || data;
},
async get(id: number): Promise<InvoiceWithLineItems> {
const { data } = await api.get(`/admin/invoices/${id}`);
return data.data || data;
},
async create(payload: InvoiceCreatePayload): Promise<InvoiceWithLineItems> {
const { data } = await api.post('/admin/invoices', payload);
return data.data || data;
},
async update(id: number, payload: Partial<InvoiceCreatePayload>): Promise<InvoiceWithLineItems> {
const { data } = await api.put(`/admin/invoices/${id}`, payload);
return data.data || data;
},
async send(id: number): Promise<{ sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/send`);
return data.data || data;
},
async markPaid(id: number, payload: {
amountMinor: number;
paidAt?: string;
paymentMethod?: string;
reference?: string;
notes?: string;
/** Migration 126 — when true the payment-log row records the
* Skonto discount amount and the invoice is treated as fully
* settled even though paid_amount_minor < total_amount_minor. */
skontoApplied?: boolean;
}): Promise<{ paidTotalMinor: number; status: InvoiceStatus }> {
const { data } = await api.post(`/admin/invoices/${id}/mark-paid`, payload);
return data.data || data;
},
async sendReminder(id: number, level?: 1 | 2): Promise<{ level: number; lateFeeMinor: number }> {
const { data } = await api.post(`/admin/invoices/${id}/send-reminder`, { level });
return data.data || data;
},
/** Cancel an invoice. For draft (`scheduled`) invoices this is a
* silent soft-cancel; for issued invoices (`sent`/`overdue`/`paid`)
* the backend generates a Stornorechnung and emails it to the
* customer automatically. `stornoId` is non-null in the issued
* case so callers can navigate to the cancellation document. */
async cancel(id: number): Promise<{ cancelled: true; stornoId: number | null }> {
const { data } = await api.post(`/admin/invoices/${id}/cancel`);
return data.data || data;
},
/** Release a pending_delivery invoice — photographer has delivered
* the photos and is ready to collect the final installment. The
* email fires immediately. */
async releaseForDelivery(id: number): Promise<{ sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/release-for-delivery`);
return data.data || data;
},
/** Fire the admin payment-check email immediately, bypassing the
* 24h throttle. Useful for testing the flow without waiting for
* the scheduled reminder window to elapse. */
async testPaymentCheck(id: number): Promise<{ token: string; sent: true }> {
const { data } = await api.post(`/admin/invoices/${id}/test-payment-check`);
return data.data || data;
},
/** Cancel & reissue — atomically cancels this invoice (via Storno
* when the original was already issued) and creates a fresh
* scheduled duplicate linked via `replacesInvoiceId`. Returns the
* new invoice's id, the original's id (`replaces`), and the
* generated Storno's id (`stornoId`, null when the original was
* already cancelled and no Storno needed to be created). Caller
* typically navigates to /admin/clients/bills/:id/edit to adjust
* before sending. */
async reissue(id: number): Promise<{ id: number; replaces: number; stornoId: number | null }> {
const { data } = await api.post(`/admin/invoices/${id}/reissue`);
return data.data || data;
},
async pdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/invoices/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async previewPdfUrl(payload: InvoiceCreatePayload): Promise<string> {
const res = await api.post('/admin/invoices/preview', payload, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
/**
* Attach a historical invoice PDF (from a previous billing system)
* to a customer's account. The backend creates a minimal invoice
* row whose imported_pdf_path points at the uploaded file —
* customer + admin PDF endpoints stream the original document.
*/
async importHistorical(payload: {
customerAccountId: number;
invoiceNumber: string;
issueDate: string;
dueDate?: string;
totalAmountMinor: number;
currency?: string;
status?: 'sent' | 'paid' | 'overdue';
paidAmountMinor?: number;
language?: string;
file: File;
}): Promise<InvoiceWithLineItems> {
const form = new FormData();
form.append('pdf', payload.file);
form.append('customerAccountId', String(payload.customerAccountId));
form.append('invoiceNumber', payload.invoiceNumber);
form.append('issueDate', payload.issueDate);
if (payload.dueDate) form.append('dueDate', payload.dueDate);
form.append('totalAmountMinor', String(payload.totalAmountMinor));
if (payload.currency) form.append('currency', payload.currency);
if (payload.status) form.append('status', payload.status);
if (payload.paidAmountMinor != null) form.append('paidAmountMinor', String(payload.paidAmountMinor));
if (payload.language) form.append('language', payload.language);
const { data } = await api.post('/admin/invoices/import', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
};
export interface CrmOverviewStats {
currency: string;
quotes: {
draft: number; sent: number; accepted: number;
declined: number; expired: number; converted: number;
};
invoices: {
scheduled: number; sent: number; paid: number;
overdue: number; cancelled: number;
};
revenue: {
monthMinor: number;
quarterMinor: number;
yearMinor: number;
};
outstanding: {
totalMinor: number;
invoiceCount: number;
};
generatedAt: string;
}
/** CRM headline metrics — quote / invoice counts, rolling revenue
* windows (30 / 90 / 365 days), outstanding payment total. Drives
* the /admin/clients/overview tab. */
export async function fetchCrmOverview(): Promise<CrmOverviewStats> {
const { data } = await api.get('/admin/dashboard/crm-stats');
return data;
}
@@ -0,0 +1,157 @@
/**
* Admin → Business profile API client.
*
* Backs the Settings → Business profile tab. Issuer block + bank account
* roster used to render every quote / invoice PDF.
*/
import { api } from '../config/api';
export type QrFormat = 'swiss' | 'epc' | 'none';
export interface BusinessProfile {
id: number;
companyName: string;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
state: string;
countryCode: string;
/** Free-text country name (migration 107). When set, the PDF
* renderer uses this verbatim; otherwise falls back to the
* locale-aware lookup on countryCode. Useful when countryCode
* carries the postal/vehicle abbreviation ("FL") rather than the
* ISO code ("LI"). */
countryName: string;
phone: string;
mobile: string;
email: string;
website: string;
vatId: string;
/** Local tax number (Steuernummer in DE/AT). Distinct from vatId
* (USt-IdNr.); §14 UStG accepts either on invoices, and the issuer
* block on every PDF renders both when present. Migration 139. */
taxId: string;
vatLabel: string;
vatRateDefault: number | null;
defaultCurrency: string;
defaultLocale: string;
defaultQrFormat: QrFormat;
footerLine: string;
logoPath: string;
/** Path (absolute or relative to storage/) to a TTF/OTF used by the
* PDF renderer. Falls back to Helvetica when blank or missing.
* The UI for setting this was retired in favour of `pdfFontFamily`
* (migration 121) but the field stays read-only on the type so
* legacy values keep flowing through. */
pdfFontTtfPath: string;
/** Bundled-fonts dropdown choice (migration 121). Stores the
* on-disk directory name under backend/assets/fonts/ (e.g.
* "Inter", "Playfair-Display"). null = no preference, Helvetica
* fallback. */
pdfFontFamily: string | null;
/** When false, the issuer logo image is suppressed on every PDF
* (even if logoPath is set). Migration 106; defaults true. */
pdfShowLogo: boolean;
/** When false, the company name line is suppressed in the issuer
* block on every PDF. Migration 106; defaults true. */
pdfShowCompanyName: boolean;
/** When true, the company name renders as a plain address line
* (same size + weight as the street) right above the address
* instead of as a bold title under the logo. Migration 108. */
pdfCompanyNameInline: boolean;
/** Logo banner height in PDF points. 24-200. Defaults to 56.
* Migration 108. */
pdfLogoHeight: number;
/** DIN 5008 folding marks on the page edge.
* 'none' (default) | 'half' | 'third' | 'both'. Migration 108. */
pdfFoldingMarks: 'none' | 'half' | 'third' | 'both';
/** When true, render the "X days from invoice date." line in the
* payment-conditions block of QUOTE PDFs. Invoices always show
* this row regardless. Migration 110; defaults FALSE. */
pdfQuoteShowNetDays: boolean;
/** When true, render the Skonto offer + "Amount with discount"
* lines in the payment-conditions block of QUOTE PDFs. Invoices
* always show these regardless. Migration 110; defaults FALSE. */
pdfQuoteShowSkonto: boolean;
/** IANA timezone string for the admin calendar (e.g. "Europe/Zurich",
* "America/New_York"). Migration 137. Admin-only — never exposed
* via publicSettings. When null/empty, the calendar UI falls back
* to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
timezone: string | null;
createdAt: string;
updatedAt: string;
}
export interface BankAccount {
id: number;
label: string;
accountHolder: string;
iban: string;
bic: string;
currency: string;
isDefault: boolean;
displayOrder: number;
createdAt: string;
updatedAt: string;
}
export interface BusinessProfileSnapshot {
profile: BusinessProfile;
bankAccounts: BankAccount[];
}
export type BusinessProfilePatch = Partial<Omit<BusinessProfile, 'id' | 'createdAt' | 'updatedAt'>>;
export type BankAccountPatch = Partial<Omit<BankAccount, 'id' | 'createdAt' | 'updatedAt'>>;
export const businessProfileService = {
async get(): Promise<BusinessProfileSnapshot> {
const { data } = await api.get('/admin/business-profile');
return data.data || data;
},
async update(payload: BusinessProfilePatch): Promise<BusinessProfileSnapshot> {
const { data } = await api.put('/admin/business-profile', payload);
return data.data || data;
},
async listBankAccounts(): Promise<{ bankAccounts: BankAccount[] }> {
const { data } = await api.get('/admin/business-profile/bank-accounts');
return data.data || data;
},
async createBankAccount(payload: BankAccountPatch & { iban: string }): Promise<{ bankAccount: BankAccount }> {
const { data } = await api.post('/admin/business-profile/bank-accounts', payload);
return data.data || data;
},
async updateBankAccount(id: number, payload: BankAccountPatch): Promise<{ bankAccount: BankAccount }> {
const { data } = await api.put(`/admin/business-profile/bank-accounts/${id}`, payload);
return data.data || data;
},
async deleteBankAccount(id: number): Promise<{ deleted: true }> {
const { data } = await api.delete(`/admin/business-profile/bank-accounts/${id}`);
return data.data || data;
},
/**
* Upload a dedicated PDF letterhead logo. Accepts PNG, JPEG, and
* SVG — the renderer rasterises SVG to PNG via sharp.
*/
async uploadLogo(file: File): Promise<{ logoPath: string }> {
const form = new FormData();
form.append('logo', file);
const { data } = await api.post('/admin/business-profile/logo', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
/** Clear the dedicated PDF logo. The renderer then falls back to
* the global branding logo (Settings → Branding). */
async clearLogo(): Promise<{ cleared: true }> {
const { data } = await api.delete('/admin/business-profile/logo');
return data.data || data;
},
};
+99
View File
@@ -0,0 +1,99 @@
/**
* Admin calendar API client.
*
* Wraps the single aggregate endpoint
* GET /api/admin/calendar/items?from=YYYY-MM-DD&to=YYYY-MM-DD
* exposed by `backend/src/routes/adminCalendar.js` (E.3).
*
* The response is a discriminated union on `kind` so the calendar page
* can switch render style per item type without juggling four separate
* react-query subscriptions.
*
* Hour-entry mutations stay on the existing customerAdmin.service —
* the calendar's drag-create / inline-edit modals call those directly.
*/
import { api } from '../config/api';
export type CalendarItemKind = 'event' | 'hours' | 'quote' | 'contract';
interface CalendarItemBase {
kind: CalendarItemKind;
customerName: string | null;
}
export interface CalendarEventItem extends CalendarItemBase {
kind: 'event';
id: number;
slug: string;
eventName: string;
eventDate: string;
eventTimeStart: string | null;
eventTimeEnd: string | null;
isFullDay: boolean;
}
export interface CalendarHoursItem extends CalendarItemBase {
kind: 'hours';
id: number;
customerAccountId: number;
entryDate: string;
startTime: string;
endTime: string;
description: string | null;
status: 'unbilled' | 'billed' | 'cancelled';
invoiceId: number | null;
invoiceStatus: string | null;
/**
* True when this entry's invoice has been armed for send (monthly
* draft cleared + scheduled_send_at set, OR status sent/paid/cancelled).
* Backend computes this via customerHoursService._internal.isEntryLocked.
* Drives the calendar's locked badge + edit-disabled state.
*/
locked: boolean;
}
export interface CalendarQuoteItem extends CalendarItemBase {
kind: 'quote';
id: number;
quoteNumber: string;
eventName: string | null;
eventDate: string;
eventTimeStart: string | null;
eventTimeEnd: string | null;
status: 'sent' | 'accepted';
}
export interface CalendarContractItem extends CalendarItemBase {
kind: 'contract';
id: number;
contractNumber: string;
eventName: string | null;
eventDate: string;
eventTimeStart: string | null;
eventTimeEnd: string | null;
status: 'signed_by_customer' | 'fully_signed';
}
export type CalendarItem =
| CalendarEventItem
| CalendarHoursItem
| CalendarQuoteItem
| CalendarContractItem;
export interface CalendarItemsResponse {
items: CalendarItem[];
range: { from: string; to: string };
}
export const calendarService = {
/**
* Fetch all four layers (events, hours, pending quotes, pending
* contracts) for the supplied date range. Both ends are inclusive,
* ISO YYYY-MM-DD. Backend caps the range at 90 days.
*/
async list(params: { from: string; to: string }): Promise<CalendarItemsResponse> {
const { data } = await api.get('/admin/calendar/items', { params });
return data.data || data;
},
};
+455
View File
@@ -0,0 +1,455 @@
/**
* Admin → Contracts API client. Hits /api/admin/contracts/*.
*
* Mirrors bills.service.ts shape: `data.data || data` unwrap, blob
* responses for PDFs via URL.createObjectURL.
*/
import { api } from '../config/api';
/** One leg of the integrity-check response (unsigned or signed PDF).
* `expected` is the stored SHA-256 column value; `actual` is freshly
* computed off the file on disk. `match` is true only when both are
* set and equal. `present:false` means the file doesn't exist on
* disk — usually expected for `signed` until the customer signs. */
export interface ContractIntegrityLeg {
path: string | null;
present: boolean;
expected: string | null;
actual: string | null;
match: boolean;
}
export interface ContractIntegrityResult {
unsigned: ContractIntegrityLeg;
signed: ContractIntegrityLeg;
}
/** Shape of one row from /admin/contracts/:id/audit-trail. */
export interface AuditEntry {
id: number;
activity_type: string;
actor_type: string | null;
actor_id: number | null;
actor_name: string | null;
metadata: Record<string, unknown>;
created_at: string;
}
export type ContractStatus =
| 'draft'
| 'sent'
| 'signed_by_customer'
| 'signed_by_admin'
| 'fully_signed'
| 'cancelled';
export type ContractSort = 'newest' | 'oldest' | 'customer_asc';
/** Canonical section enum kept in sync with backend SECTIONS_ORDER
* and contractBlocksService.ALLOWED_SECTIONS. Renaming any value
* here also needs a backend update — there's a test that guards it. */
export type ContractBlockSection =
| 'basics'
| 'scope'
| 'privacy'
| 'commercial'
| 'nda'
| 'closing';
export const CONTRACT_SECTIONS: ContractBlockSection[] = [
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
];
export interface ContractBlock {
id: number;
slug: string;
section: ContractBlockSection;
name: string;
description: string | null;
bodyText: string;
bodyTextDe: string | null;
/** Migration 131 — locale-variant bodies. Null until the admin
* fills them in via the block library editor. Render context falls
* back EN when the contract's locale has no translation. */
bodyTextRu: string | null;
bodyTextPt: string | null;
bodyTextNl: string | null;
bodyTextFr: string | null;
isSystem: boolean;
isActive: boolean;
displayOrder: number;
createdAt: string;
updatedAt: string;
}
export interface ContractBlockInclusion {
id: number;
blockId: number;
section: ContractBlockSection;
position: number;
included: boolean;
block: {
slug: string;
name: string;
description: string | null;
bodyText: string;
bodyTextDe: string | null;
isSystem: boolean;
};
bodyTextSnapshot: string | null;
bodyTextDeSnapshot: string | null;
}
export interface ContractSummary {
id: number;
contractNumber: string;
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
customer: {
email: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
companyName: string | null;
preferredLanguage?: string | null;
};
status: ContractStatus;
language: string;
issueDate: string;
validUntil: string | null;
title: string | null;
/** Event snapshot fields (migration 130 in-place edit). Mirror
* quotes.event_* + invoices.event_* so the label flows through
* quote → contract → invoice unchanged. Null when the standalone
* contract didn't set them OR when the DB hasn't re-migrated yet. */
eventName?: string | null;
eventDate?: string | null;
eventTimeStart?: string | null;
eventTimeEnd?: string | null;
introText: string | null;
outroText: string | null;
pdfPath: string | null;
signedPdfPath: string | null;
/** SHA-256 hex digest of the on-disk PDF — surfaced for the
* audit-trail panel so the admin (and the customer in their
* audit confirmation) can verify file integrity by re-hashing. */
pdfSha256?: string | null;
signedPdfSha256?: string | null;
/** Migration 136 — post-sign PDF re-stamp failure marker. When non-
* null, the most recent stamp attempt threw and the contract is in
* an orphan state (status is signed_by_customer or signed_by_admin
* but signed_pdf_path is missing). Detail page surfaces a recovery
* banner pointing at the resend-signed / restamp-signatures admin
* routes. Cleared by any successful subsequent stamp. */
signedPdfRenderFailedAt?: string | null;
signedPdfRenderError?: string | null;
sentAt: string | null;
signedByCustomerAt: string | null;
signedByAdminAt: string | null;
signedCustomerName: string | null;
signedAdminName: string | null;
/** Disk paths to the captured signature PNGs. Surfaced only so the
* UI can show a "(no image)" hint next to evidence rows whose
* customer/admin signature didn't capture (e.g. old canvas bug);
* the paths themselves are never exposed in user-facing strings. */
signedCustomerSignaturePath?: string | null;
signedAdminSignaturePath?: string | null;
createdByAdminId: number | null;
/** Lineage back-pointers (migration 130). Used by the detail page to
* render "Linked quote" + "Linked invoices" panels. Null when the
* contract was created standalone or when the DB lineage columns
* haven't migrated yet. */
sourceQuoteId?: number | null;
convertedEventId?: number | null;
createdAt: string;
updatedAt: string;
inclusions?: ContractBlockInclusion[];
}
export type ContractDetail = ContractSummary & {
inclusions: ContractBlockInclusion[];
};
export interface ContractListResponse {
contracts: ContractSummary[];
total: number;
page: number;
pageSize: number;
}
export interface ContractCreatePayload {
customerAccountId: number;
language?: string;
title?: string | null;
/** Event snapshot fields — same shape as the quote editor. */
eventName?: string | null;
eventDate?: string | null;
eventTimeStart?: string | null;
eventTimeEnd?: string | null;
introText?: string | null;
outroText?: string | null;
issueDate?: string;
validUntil?: string;
}
export interface ContractUpdatePayload {
title?: string | null;
eventName?: string | null;
eventDate?: string | null;
eventTimeStart?: string | null;
eventTimeEnd?: string | null;
introText?: string | null;
outroText?: string | null;
language?: string;
issueDate?: string;
validUntil?: string;
/** Full list of inclusions to write. Server rewrites the inclusion
* rows from this payload — caller controls inclusion + per-section
* order via the position field. Omit to leave inclusions untouched. */
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
}
export interface ContractBlockCreatePayload {
section: ContractBlockSection;
name: string;
bodyText: string;
bodyTextDe?: string | null;
bodyTextRu?: string | null;
bodyTextPt?: string | null;
bodyTextNl?: string | null;
bodyTextFr?: string | null;
description?: string | null;
displayOrder?: number;
isActive?: boolean;
}
export type ContractBlockUpdatePayload = Partial<ContractBlockCreatePayload>;
export const contractsService = {
async list(params: {
status?: ContractStatus[];
customerAccountId?: number;
q?: string;
sort?: ContractSort;
page?: number;
pageSize?: number;
} = {}): Promise<ContractListResponse> {
const { data } = await api.get('/admin/contracts', {
params: { ...params, status: params.status?.join(',') },
});
return data.data || data;
},
async get(id: number): Promise<{ contract: ContractDetail }> {
const { data } = await api.get(`/admin/contracts/${id}`);
return data.data || data;
},
async create(payload: ContractCreatePayload): Promise<{ contract: ContractDetail }> {
const { data } = await api.post('/admin/contracts', payload);
return data.data || data;
},
async update(id: number, payload: ContractUpdatePayload): Promise<{ contract: ContractDetail }> {
const { data } = await api.put(`/admin/contracts/${id}`, payload);
return data.data || data;
},
async send(id: number): Promise<{ token: string; pdfPath: string | null }> {
const { data } = await api.post(`/admin/contracts/${id}/send`);
return data.data || data;
},
async cancel(id: number): Promise<{ status: 'cancelled' }> {
const { data } = await api.post(`/admin/contracts/${id}/cancel`);
return data.data || data;
},
/** Convert a fully-signed contract into an event + scheduled invoices.
* Requires source_quote_id (no line items otherwise). Idempotent — if
* the contract already has converted_event_id set the same event id
* comes back with alreadyConverted: true. */
async convertToEvent(id: number): Promise<{ eventId: number; alreadyConverted: boolean }> {
const { data } = await api.post(`/admin/contracts/${id}/convert-to-event`);
return data.data || data;
},
/** Convert a fully-signed contract directly into invoice(s) — no event. */
async convertToInvoice(id: number): Promise<{ installmentsCreated: number }> {
const { data } = await api.post(`/admin/contracts/${id}/convert-to-invoice`);
return data.data || data;
},
/** Re-render the signed PDF (system-render path only — wet-signed
* uploads are preserved) and resend the contract_fully_signed
* email to both parties. Recovery action for contracts where the
* initial dual-party send failed silently. */
async resendSigned(id: number): Promise<{ signedPdfPath: string; resent: true }> {
const { data } = await api.post(`/admin/contracts/${id}/resend-signed`);
return data.data || data;
},
/** Re-stamp one or both signature images on a contract whose
* original sign happened before the canvas worked correctly.
* Either dataUrl may be null/omitted — the corresponding image
* is then left untouched. Always re-renders + persists the PDF. */
async restampSignatures(
id: number,
payload: { customerSignatureDataUrl?: string | null; adminSignatureDataUrl?: string | null },
): Promise<{ signedPdfPath: string; stamped: { customer: boolean; admin: boolean } }> {
const { data } = await api.post(`/admin/contracts/${id}/restamp-signatures`, payload);
return data.data || data;
},
async countersign(
id: number,
payload: { name: string; signatureDataUrl?: string | null },
): Promise<{ status: ContractStatus; signedAt: string }> {
const { data } = await api.post(`/admin/contracts/${id}/countersign`, payload);
return data.data || data;
},
async uploadSignedPdf(id: number, file: File): Promise<{ status: 'fully_signed'; signedPdfPath: string }> {
const form = new FormData();
form.append('file', file);
const { data } = await api.post(`/admin/contracts/${id}/upload-signed-pdf`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
async pdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/contracts/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async signedPdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/contracts/${id}/signed-pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async previewPdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/contracts/${id}/preview`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
/** Audit trail — chronological activity_logs entries for this
* contract. Renders the timeline on the detail page so the admin
* has a single-pane view of every event (sent, signed, counter-
* signed, resent emails, conversions). */
async auditTrail(id: number): Promise<{ entries: AuditEntry[] }> {
const { data } = await api.get(`/admin/contracts/${id}/audit-trail`);
return data.data || data;
},
/** Integrity check — re-hashes the unsigned + signed PDF on disk
* and compares each to the stored SHA-256 column from migration
* 131. Used by the IntegrityCheckCard on ContractDetailPage; the
* admin clicks once to confirm no backup-corruption / manual edit
* has altered the document since it was issued. */
async verifyIntegrity(id: number): Promise<ContractIntegrityResult> {
const { data } = await api.get(`/admin/contracts/${id}/verify-integrity`);
return data.data || data;
},
// ----- Block library -------------------------------------------------
async listBlocks(params: { section?: ContractBlockSection; includeInactive?: boolean } = {}): Promise<{ blocks: ContractBlock[] }> {
const { data } = await api.get('/admin/contracts/blocks', { params });
return data.data || data;
},
async createBlock(payload: ContractBlockCreatePayload): Promise<{ block: ContractBlock }> {
const { data } = await api.post('/admin/contracts/blocks', payload);
return data.data || data;
},
async updateBlock(id: number, payload: ContractBlockUpdatePayload): Promise<{ block: ContractBlock }> {
const { data } = await api.put(`/admin/contracts/blocks/${id}`, payload);
return data.data || data;
},
async deleteBlock(id: number): Promise<{ ok: true }> {
const { data } = await api.delete(`/admin/contracts/blocks/${id}`);
return data.data || data;
},
};
// ===================================================================
// Public client — used by ContractResponsePage (no auth, token-based).
// ===================================================================
export interface PublicContractView {
contractNumber: string;
status: ContractStatus;
language: string;
issueDate: string;
validUntil: string | null;
title: string | null;
introText: string | null;
outroText: string | null;
sentAt: string | null;
signedByCustomerAt: string | null;
signedByAdminAt: string | null;
signedCustomerName: string | null;
signedAdminName: string | null;
/** Customer IP at signing — surfaced to the customer so they can
* verify what we recorded about THEM. Admin's counter-sign IP is
* intentionally NOT in this shape; it's the operator's identifier
* and not part of the customer's audit surface. */
signedCustomerIp?: string | null;
hasSignedPdf: boolean;
/** SHA-256 hashes of the on-disk PDFs — shown in the audit
* confirmation so the customer can re-hash their copy. */
pdfSha256?: string | null;
signedPdfSha256?: string | null;
canSign: boolean;
sections: Array<{
section: ContractBlockSection;
blocks: Array<{
blockId: number;
section: ContractBlockSection;
position: number;
name: string;
body: string;
}>;
}>;
recipient: {
displayName: string;
companyName: string | null;
email: string;
} | null;
issuer: {
companyName: string | null;
addressLine1: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
website: string | null;
} | null;
/** Admin-set behaviour flags surfaced for the public sign page.
* Server re-enforces both — these only drive the UI. */
allowPdfUpload?: boolean;
requireDrawnSignature?: boolean;
}
export const publicContractsService = {
async get(token: string): Promise<{ contract: PublicContractView }> {
const { data } = await api.get(`/public/contracts/${token}`);
return data.data || data;
},
async sign(token: string, payload: { name: string; signatureDataUrl?: string | null; accepted: true }): Promise<{ status: ContractStatus; signedAt: string }> {
const { data } = await api.post(`/public/contracts/${token}/sign`, payload);
return data.data || data;
},
async uploadSignedPdf(token: string, file: File): Promise<{ status: 'fully_signed'; signedPdfPath: string }> {
const form = new FormData();
form.append('file', file);
const { data } = await api.post(`/public/contracts/${token}/upload-signed-pdf`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.data || data;
},
};
+127
View File
@@ -244,4 +244,131 @@ export const customerService = {
);
return response.data;
},
// ---- CRM (customer-side, read-only) ----
async listQuotes(): Promise<CustomerQuote[]> {
const response = await api.get<{ quotes: CustomerQuote[] }>('/customer/quotes');
return response.data.quotes;
},
async listInvoices(): Promise<CustomerInvoice[]> {
const response = await api.get<{ invoices: CustomerInvoice[] }>('/customer/invoices');
return response.data.invoices;
},
/** Returns a blob URL ready for window.open(). */
async invoicePdfUrl(id: number): Promise<string> {
const res = await api.get(`/customer/invoices/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
/** Returns a blob URL for the quote PDF (customer-side). */
async quotePdfUrl(id: number): Promise<string> {
const res = await api.get(`/customer/quotes/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
// ---- Contracts (customer-side) ----
async listContracts(): Promise<CustomerContract[]> {
const response = await api.get<{ contracts: CustomerContract[] }>('/customer/contracts');
return response.data.contracts;
},
/** Streams the signed PDF when available, otherwise the system-
* rendered PDF. The backend handles the fallback so the frontend
* just opens whatever it gets back. */
async contractPdfUrl(id: number): Promise<string> {
const res = await api.get(`/customer/contracts/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
};
export interface CustomerQuote {
id: number;
quoteNumber: string;
status: 'draft' | 'sent' | 'accepted' | 'declined' | 'expired' | 'converted';
currency: string;
issueDate: string;
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
totalAmountMinor: number;
introText: string | null;
outroText: string | null;
sentAt: string | null;
respondedAt: string | null;
responseLockedAt: string | null;
acceptedAt: string | null;
declinedAt: string | null;
/** Token to open the public response page from the customer
* dashboard. null when expired/used. */
responseToken: string | null;
}
export interface CustomerInvoice {
id: number;
/** Document discriminator. 'invoice' is the default; 'storno' rows
* are Stornorechnungen (cancellation invoices) and render with a
* distinct badge + lineage banner. */
kind: 'invoice' | 'storno';
invoiceNumber: string;
/** `cancelled` only appears on the customer side for invoices that
* were formally reversed via Stornorechnung (cancellation_storno_id
* IS NOT NULL). Soft-cancelled drafts stay hidden server-side. */
status: 'sent' | 'paid' | 'overdue' | 'cancelled';
currency: string;
issueDate: string;
dueDate: string;
installmentIndex: number;
installmentTotal: number;
installmentLabel: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
totalAmountMinor: number;
paidAmountMinor: number;
paidAt: string | null;
lateFeeAmountMinor: number;
reminderLevel: number;
sentAt: string | null;
/** On a Storno row (kind='storno') → id of the invoice it reverses. */
cancelsInvoiceId: number | null;
/** Human invoice_number of the row referenced by `cancelsInvoiceId`,
* joined server-side so the customer view can show the actual
* invoice number instead of the bare row id. */
cancelsInvoiceNumber: string | null;
/** On a cancelled invoice → id of the Storno that cancelled it. */
cancellationStornoId: number | null;
/** Human invoice_number of the Storno referenced by
* `cancellationStornoId`. */
cancellationStornoNumber: string | null;
/** Inline event snapshot (migration 123) — rendered next to the
* invoice number on the customer portal bills list. */
eventName: string | null;
eventDate: string | null;
}
export interface CustomerContract {
id: number;
contractNumber: string;
status: 'sent' | 'signed_by_customer' | 'signed_by_admin' | 'fully_signed' | 'cancelled';
language: string;
issueDate: string;
validUntil: string | null;
title: string | null;
sentAt: string | null;
signedByCustomerAt: string | null;
signedByAdminAt: string | null;
signedCustomerName: string | null;
signedAdminName: string | null;
hasPdf: boolean;
hasSignedPdf: boolean;
/** Live signing-link token for `sent` contracts so the dashboard can
* deep-link the public sign page when the customer lost the email. */
responseToken: string | null;
}
@@ -15,6 +15,11 @@ export interface CustomerAccountSummary {
salutation: string | null;
companyName: string | null;
isActive: boolean;
/** Passive = admin-only customer with no portal access (password_hash IS NULL).
* The backend never returns the actual hash; this boolean is computed
* server-side in transformCustomer. Drives the "Passive — admin only"
* badge + the "Send portal invitation" button on the detail page. */
isPassive?: boolean;
lastLogin: string | null;
createdAt: string;
eventCount?: number;
@@ -22,6 +27,13 @@ export interface CustomerAccountSummary {
featureCalendar?: boolean;
featureQuotes?: boolean;
featureBills?: boolean;
/** Per-customer hour logging (migration 129). When on, the customer
* detail page renders the "Hours" section card. */
featureHoursLogging?: boolean;
/** Default hourly rate in minor units (e.g. CHF 150.00 = 15000).
* null when admin hasn't set one — each entry then requires a
* per-block override. */
hourlyRateMinor?: number | null;
}
export interface CustomerAccountDetail extends CustomerAccountSummary {
@@ -34,7 +46,20 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
city: string | null;
state: string | null;
countryCode: string | null;
/** Free-text country name (migration 107). PDF renderer uses this
* verbatim when set; otherwise falls back to the locale-aware
* lookup on countryCode. Useful when countryCode is the postal /
* vehicle abbreviation ("FL") rather than the ISO code ("LI"). */
countryName: string | null;
preferredLanguage: string;
/**
* CRM billing cadence override (migration 102).
* - 'per_event' (default): respect each quote's installment plan
* - 'monthly' / 'quarterly': snap every scheduled invoice to
* `billingCycleDay` of the next period.
*/
billingCadence?: 'per_event' | 'monthly' | 'quarterly';
billingCycleDay?: number;
notes: string | null;
events: Array<{
id: number;
@@ -62,6 +87,10 @@ export interface CustomerInvitePrefill {
city?: string;
state?: string;
country_code?: string;
country_name?: string;
/** ISO 639 / BCP-47 locale code. Defaults at insert time to the
* business profile's default_locale when not supplied. */
preferred_language?: string;
}
export interface CustomerInvitationSummary {
@@ -115,6 +144,7 @@ export const customerAdminService = {
city: 'city',
state: 'state',
countryCode: 'country_code',
countryName: 'country_name',
preferredLanguage: 'preferred_language',
notes: 'notes',
isActive: 'is_active',
@@ -122,6 +152,12 @@ export const customerAdminService = {
featureCalendar: 'feature_calendar',
featureQuotes: 'feature_quotes',
featureBills: 'feature_bills',
featureHoursLogging: 'feature_hours_logging',
// Hour-logging default rate (migration 129).
hourlyRateMinor: 'hourly_rate_minor',
// CRM billing cadence (migration 102 + 128).
billingCadence: 'billing_cadence',
billingCycleDay: 'billing_cycle_day',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
@@ -205,4 +241,188 @@ export const customerAdminService = {
async cancelInvitation(id: number): Promise<void> {
await api.delete(`/admin/customers/invitations/${id}`);
},
/**
* Create a "passive" customer directly — admin-only record with no
* portal access, no invitation, no email. The customer is created
* with `password_hash = NULL`; the auth middleware rejects login
* for those, so the customer physically can't access the portal
* until the admin promotes them via `sendInvite()`.
*
* Used by the quote/invoice editor's "+ Create new customer" inline
* form: lets the admin spin up an identity in seconds for one-off
* projects (where issuing portal credentials would be overkill).
*/
async createDirect(
email: string,
prefill?: CustomerInvitePrefill,
): Promise<CustomerAccountDetail> {
const response = await api.post<{ data: { customer: CustomerAccountDetail } } | { customer: CustomerAccountDetail }>(
'/admin/customers',
{ email, prefill },
);
return ((response.data as any).data ?? response.data).customer;
},
/**
* Promote a passive customer to active by firing the standard
* portal-invitation email. The customer clicks the link, lands on
* the accept page (pre-populated with their existing profile),
* sets a password, and is now active. The customer's id is
* preserved across promotion — all their existing invoices,
* quotes, and gallery assignments survive.
*
* Rejects with 409 CUSTOMER_ALREADY_ACTIVE if the customer
* already has a password set.
*/
async sendInvite(id: number): Promise<{ id: number; email: string; expiresAt: string }> {
const response = await api.post<{ data: { invitation: { id: number; email: string; expiresAt: string } } }>(
`/admin/customers/${id}/send-invite`,
);
return (response.data as any).data?.invitation ?? (response.data as any).invitation;
},
// -------------------------------------------------------------------
// Hour entries (migration 129).
// -------------------------------------------------------------------
async listHourEntries(customerId: number, status?: HourEntryStatus): Promise<HourEntry[]> {
const response = await api.get<{ data: { entries: HourEntry[] } }>(
`/admin/customers/${customerId}/hour-entries`,
{ params: status ? { status } : undefined },
);
return ((response.data as any).data?.entries ?? (response.data as any).entries) || [];
},
async createHourEntry(
customerId: number,
payload: HourEntryCreatePayload,
): Promise<{ id: number; status: HourEntryStatus; invoiceId?: number }> {
const response = await api.post(
`/admin/customers/${customerId}/hour-entries`,
payload,
);
return (response.data as any).data ?? response.data;
},
async updateHourEntry(
customerId: number,
entryId: number,
payload: HourEntryUpdatePayload,
): Promise<{ id: number }> {
const response = await api.put(
`/admin/customers/${customerId}/hour-entries/${entryId}`,
payload,
);
return (response.data as any).data ?? response.data;
},
async deleteHourEntry(customerId: number, entryId: number): Promise<{ deleted: true }> {
const response = await api.delete(
`/admin/customers/${customerId}/hour-entries/${entryId}`,
);
return (response.data as any).data ?? response.data;
},
/** Per-event flow only — mints a standalone invoice from all
* unbilled entries and stamps them billed. Monthly-mode customers
* auto-bill on save and get a 409 here. */
async billUnbilledHourEntries(customerId: number): Promise<{ invoiceId: number; entriesBilled: number }> {
const response = await api.post(
`/admin/customers/${customerId}/hour-entries/bill`,
);
return (response.data as any).data ?? response.data;
},
/** Admin override — issue the customer's running monthly draft now,
* bypassing the cadence-day wait. 409 when no draft exists or the
* draft is empty. Returns the issued invoice id + number. */
async triggerMonthlyBill(customerId: number): Promise<{ invoiceId: number; invoiceNumber: string }> {
const response = await api.post(
`/admin/customers/${customerId}/trigger-monthly-bill`,
);
return (response.data as any).data ?? response.data;
},
/** Preview the customer's open monthly draft (line items + totals).
* Returns null when nothing has been queued for the current period. */
async getMonthlyDraft(customerId: number): Promise<{ draft: MonthlyDraftPreview | null }> {
const response = await api.get(
`/admin/customers/${customerId}/monthly-draft`,
);
return (response.data as any).data ?? response.data;
},
};
/** Open monthly bill accumulator preview (migration 128). One row in
* the invoices table with is_monthly_draft=true that gathers every
* invoice line created for this customer during the current period;
* ships on the cadence day or via triggerMonthlyBill. */
export interface MonthlyDraftPreview {
id: number;
invoiceNumber: string;
currency: string;
periodStart: string;
periodEnd: string;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
totalAmountMinor: number;
lineItems: MonthlyDraftLineItem[];
}
export interface MonthlyDraftLineItem {
id: number;
position: number;
quantity: number;
description: string;
unitPriceMinor: number;
discountPercent: number;
lineTotalMinor: number;
parentPosition: number | null;
detailsText: string;
}
// -------------------------------------------------------------------
// Hour-entry types (migration 129)
// -------------------------------------------------------------------
export type HourEntryStatus = 'unbilled' | 'billed' | 'cancelled';
export interface HourEntry {
id: number;
customerAccountId: number;
entryDate: string;
startTime: string;
endTime: string;
durationMinutes: number;
hourlyRateMinorOverride: number | null;
description: string | null;
status: HourEntryStatus;
invoiceId: number | null;
invoiceLineItemId: number | null;
invoiceNumber: string | null;
invoiceStatus: string | null;
invoiceIsMonthlyDraft: boolean;
invoiceScheduledSendAt: string | null;
billedAt: string | null;
recordedByAdminId: number | null;
createdAt: string;
updatedAt: string;
}
export interface HourEntryCreatePayload {
entryDate: string; // YYYY-MM-DD
startTime: string; // HH:MM
endTime: string; // HH:MM
hourlyRateMinorOverride?: number | null;
description?: string | null;
}
export interface HourEntryUpdatePayload {
entryDate?: string;
startTime?: string;
endTime?: string;
hourlyRateMinorOverride?: number | null;
description?: string | null;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* dealsService — typed wrappers for the `/api/admin/deals/:uuid/...`
* routes (migration 140's deal_uuid grouping). Read surface lives in
* `DocumentLineageCard` via inline `useQuery`; mutations land here so
* call sites stay tidy.
*/
import { api } from '../config/api';
import type { PaymentTermInstallment } from './quotes.service';
export interface UpdateInstallmentPlanResult {
invoiceIds: number[];
kept: number[];
created: number[];
deleted: number[];
}
export const dealsService = {
/**
* Atomically reshape an installment plan after siblings have spawned.
* See backend invoiceService.updateInstallmentPlan for the full
* guard/reuse/grow/trim semantics. 409 responses carry one of:
* - INVOICE_LOCKED — at least one sibling is past scheduled
* - PLAN_HAS_STORNO — a Storno already exists on the deal
* 400 responses carry NOT_INSTALLMENT_PLAN or PERCENT_SUM_INVALID.
*/
async updateInstallmentPlan(
dealUuid: string,
installments: PaymentTermInstallment[],
): Promise<UpdateInstallmentPlanResult> {
const res = await api.put(`/admin/deals/${dealUuid}/installment-plan`, {
installments,
});
return (res.data?.data || res.data) as UpdateInstallmentPlanResult;
},
};
export default dealsService;
+46
View File
@@ -0,0 +1,46 @@
/**
* Admin → Dev tools API client. Gated server-side by the
* `crmDevelopment` feature flag; the frontend additionally hides
* the page when the flag is off, but the API check is what
* actually enforces the gate.
*/
import { api } from '../config/api';
export type CrmEmailTemplateKey =
| 'quote_sent'
| 'quote_accepted_customer'
| 'quote_accepted_admin'
| 'quote_declined_admin'
| 'invoice_sent'
| 'invoice_reminder_first'
| 'invoice_reminder_second'
| 'invoice_payment_check_admin'
// Contracts (migration 130). Backend exposes all three flows via the
// dev tester (admin → customer send, customer-signed ping, dual-party
// fully-signed). Without these in the union, useQuery returns rows
// whose `key` doesn't resolve to a label and they render as raw
// template_key strings.
| 'contract_sent'
| 'contract_signed_admin_notification'
| 'contract_fully_signed';
export interface DevEmailTemplateStatus {
key: CrmEmailTemplateKey;
present: boolean;
}
export const devToolsService = {
async listEmailTemplates(): Promise<DevEmailTemplateStatus[]> {
const { data } = await api.get('/admin/dev/email-templates');
return (data.data || data).templates;
},
async sendTestEmail(templateKey: CrmEmailTemplateKey): Promise<{
sent: true;
to: string;
template: CrmEmailTemplateKey;
}> {
const { data } = await api.post('/admin/dev/send-test-email', { templateKey });
return data.data || data;
},
};
+16
View File
@@ -91,6 +91,22 @@ export const emailService = {
await api.put(`/admin/email/templates/${key}`, data);
},
/** Create a new email template. Used by ReminderTemplatesPage to
* spawn a per-event-type reminder (e.g. `event_reminder_wedding`)
* initialised with the default catch-all's content. Returns 409 if
* the key already exists. */
async createTemplate(payload: {
template_key: string;
translations: Record<string, EmailTemplateTranslation>;
category?: string;
subcategory?: string;
feature_flag?: string;
variables?: string[];
}): Promise<{ template_key: string; id: number }> {
const response = await api.post<{ template_key: string; id: number }>('/admin/email/templates', payload);
return response.data;
},
// Preview email template
async previewTemplate(key: string, previewData: Record<string, string>, language: string = 'en'): Promise<EmailPreview> {
const response = await api.post<EmailPreview>(
+23 -1
View File
@@ -19,7 +19,29 @@ export type FeatureKey =
// Customer-side portal surface (#354). Gates /customer/* routes
// (login, dashboard, profile, accept-invite, reset-password) and
// the Accounts sub-page under Clients in the admin UI.
| 'customerPortal';
| 'customerPortal'
// CRM developer tools — hidden by default. When enabled, surfaces
// a "Development" sub-tab under Clients with internal-use buttons
// (test the payment-check email flow, fire dev-only side effects,
// etc.). Strictly opt-in; toggled from Features.
| 'crmDevelopment'
// Tax / Steuer report — period-scoped revenue export under
// Clients. Independent of `bills` so admins who already use the
// billing surface don't get a new menu entry automatically; they
// opt in when they're ready to generate tax reports.
| 'taxReport'
// Hours logging (migration 129). Master switch for the per-customer
// Hours card on the customer detail page. Independent of `bills`
// so admin can log hours without enabling the broader billing
// surface yet.
| 'hoursLogging'
// Contracts (migration 130). Standalone legal-document type alongside
// quotes / bills, composed from a library of reusable blocks and
// signed in-browser (canvas + checkbox) or via wet-signed PDF
// upload. Independent of quotes / bills — contracts can be sent on
// their own. Seeded block bodies are examples only; admins must
// have their lawyer review before sending. See docs/crm-disclaimers.md.
| 'contracts';
export type FeatureFlags = Record<FeatureKey, boolean>;
@@ -0,0 +1,55 @@
/**
* Public payment-check API client — no auth header. The token in
* the URL is the only credential.
*/
import { api } from '../config/api';
export type PaymentCheckAction = 'paid_full' | 'paid_with_skonto' | 'partial' | 'unpaid';
export interface PaymentCheckView {
invoiceNumber: string;
customer: { label: string; email?: string };
issueDate: string;
dueDate: string;
totalMinor: number;
paidMinor: number;
lateFeeMinor: number;
outstandingMinor: number;
currency: string;
status: string;
reminderLevel: number;
expiresAt: string;
/** Migration 126 — Skonto pre-resolution from the invoice's payment
* terms snapshot. When `hasSkonto` is true the public page renders
* the "Paid with Skonto" action card; otherwise hidden. */
hasSkonto: boolean;
skontoPercent: number | null;
skontoDiscountedTotalMinor: number | null;
}
export interface PaymentCheckIssuer {
companyName: string;
email?: string;
website?: string;
logoUrl: string | null;
}
export interface PaymentCheckResponse {
invoice: PaymentCheckView;
issuer: PaymentCheckIssuer | null;
}
export const paymentCheckService = {
async get(token: string): Promise<PaymentCheckResponse> {
const { data } = await api.get(`/public/payment-check/${token}`);
const body = data.data || data;
return { invoice: body.invoice, issuer: body.issuer || null };
},
async record(token: string, payload: {
action: PaymentCheckAction;
amountMinor?: number;
}): Promise<{ applied: PaymentCheckAction; reminderLevel?: number; reminderSkipped?: string }> {
const { data } = await api.post(`/public/payment-check/${token}`, payload);
return data.data || data;
},
};
@@ -14,7 +14,12 @@ export interface PublicSettings {
branding_logo_url: string;
branding_logo_size?: string;
branding_logo_max_height?: number;
branding_logo_position?: 'left' | 'center' | 'right';
/**
* Logo placement. 'left' | 'center' | 'right' = inside the gallery
* header. 'sidepanel' = moved into the admin sidebar's brand row
* (admin chrome only — gallery falls back to 'left').
*/
branding_logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
branding_logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
branding_logo_display_header?: boolean;
branding_logo_display_hero?: boolean;
@@ -46,6 +51,15 @@ export interface PublicSettings {
default_language: string;
enable_analytics: boolean;
general_date_format: string | { format: string; locale: string };
/** '12h' or '24h' — controls how times are rendered in admin +
* customer views. Storage is always 24h; only display toggles. */
general_time_format?: '12h' | '24h';
/** Dashboard CRM-overview tile visibility. All default true; only
* explicit false hides the matching tile. */
crm_overview_show_revenue?: boolean;
crm_overview_show_outstanding?: boolean;
crm_overview_show_quotes?: boolean;
crm_overview_show_invoices?: boolean;
enable_recaptcha: boolean;
recaptcha_site_key: string | null;
maintenance_mode: boolean;
+396
View File
@@ -0,0 +1,396 @@
/**
* Admin → Quotes API client. Hits /api/admin/quotes/* (admin auth) and
* /api/public/quotes/:token for the customer-facing accept/decline page.
*/
import { api } from '../config/api';
export type QuoteStatus = 'draft' | 'sent' | 'accepted' | 'declined' | 'expired' | 'converted';
export type QuoteSort = 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc';
export interface QuoteLineItem {
id?: number;
position: number;
quantity: number;
description: string;
unitPriceMinor: number;
discountPercent: number;
lineTotalMinor?: number;
/**
* Hierarchy (migration 119). Sub-items reference their parent by
* position within the same payload. NULL = top-level item, summed
* into the document net. NON-NULL = sub-item, display-only
* itemisation under the parent. Max one level deep.
*/
parentPosition?: number | null;
/** Persisted DB id of the parent, populated by the server on read. */
parentLineItemId?: number | null;
/**
* Optional free-form notes rendered below the description on the
* PDF and customer view. Smaller, italic. Max 2000 chars.
*/
detailsText?: string | null;
}
export interface QuoteSummary {
id: number;
quoteNumber: string;
/** Cross-document lineage UUID (migration 140). Used by the
* DocumentLineageCard on the detail page to fetch every other
* doc — contract, invoices, Storni — that shares this deal. */
dealUuid: string | null;
customerAccountId: number;
customer: {
email: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
companyName: string | null;
/** Computed server-side from password_hash. true = admin-only
* customer (no portal access). Drives the Passive badge in the
* editor pill + the quotes list. */
isPassive?: boolean;
};
status: QuoteStatus;
language: string;
currency: string;
issueDate: string;
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
totalAmountMinor: number;
sentAt: string | null;
acceptedAt: string | null;
declinedAt: string | null;
convertedEventId: number | null;
/** Migration 130 — set by contractService.createFromQuote so the
* QuoteDetailPage can render a "Linked contract" badge alongside
* the existing resulting-invoices list. Null when no contract was
* drafted from the quote. */
convertedContractId?: number | null;
/** Human contract_number of the converted contract (joined-in on
* read). Surfaced so the QuoteDetailPage's Linked-documents card
* shows "Linked contract LBM-C-2026-0010" instead of "#10". */
convertedContractNumber?: string | null;
createdAt: string;
}
export interface QuoteDetail extends QuoteSummary {
eventTimeStart: string | null;
eventTimeEnd: string | null;
expectedDurationHours: number | null;
paymentTermTemplateId: number | null;
/** Migration 124 — split payment-term picker. Two new FKs preferred
* by the editor; legacy `paymentTermTemplateId` stays for sent
* quotes authored before the split. */
paymentNetDaysTemplateId: number | null;
paymentTimingTemplateId: number | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
introText: string | null;
outroText: string | null;
internalNotes: string | null;
ccPdfEmail: string | null;
respondedAt: string | null;
responseLockedAt: string | null;
pdfPath: string | null;
businessBankAccountId: number | null;
}
export interface QuoteWithLineItems {
quote: QuoteDetail;
lineItems: QuoteLineItem[];
}
export interface PaymentTermInstallment {
label: string;
percent: number;
trigger: 'quote_accepted' | 'before_event' | 'after_event' | 'after_delivery' | 'fixed_date';
offset_days: number;
}
export interface PaymentTermTemplate {
id: number;
name: string;
description: string;
netDays: number;
skontoPercent: number | null;
skontoWithinDays: number | null;
installments: PaymentTermInstallment[];
isSystem: boolean;
isActive: boolean;
displayOrder: number;
}
// Migration 124 — split payment-term picker. Two new template tables
// replace the conflated PaymentTermTemplate for new quotes/invoices.
// The old type stays for back-compat with sent documents whose
// snapshot still references the legacy table.
export interface PaymentNetDaysTemplate {
id: number;
name: string;
description: string | null;
netDays: number;
skontoPercent: number | null;
skontoWithinDays: number | null;
isSystem: boolean;
isActive: boolean;
displayOrder: number;
}
export interface PaymentTimingTemplate {
id: number;
name: string;
description: string | null;
installments: PaymentTermInstallment[];
isSystem: boolean;
isActive: boolean;
displayOrder: number;
}
export interface LineItemPreset {
id: number;
name: string;
description: string;
unitPriceMinor: number;
currency: string;
quantityDefault: number;
displayOrder: number;
isActive: boolean;
}
export interface QuoteCreatePayload {
customerAccountId: number;
language?: string;
currency?: string;
issueDate?: string;
validUntil?: string;
eventName?: string;
eventDate?: string;
eventTimeStart?: string;
eventTimeEnd?: string;
expectedDurationHours?: number;
paymentTermTemplateId?: number;
/** Migration 124 — split payment-term picker. Both must be set
* together for the new path to engage on the backend. */
paymentNetDaysTemplateId?: number;
paymentTimingTemplateId?: number;
/** Ad-hoc installments (commit #6). Overrides the picked timing
* template's installments on the snapshot. Empty/missing = use
* the template's value as-is. */
installments?: PaymentTermInstallment[];
vatRate?: number;
shippingAmountMinor?: number;
introText?: string;
outroText?: string;
internalNotes?: string;
ccPdfEmail?: string;
businessBankAccountId?: number;
lineItems: QuoteLineItem[];
}
export interface QuoteListResponse {
quotes: QuoteSummary[];
pagination: { total: number; page: number; pageSize: number; totalPages: number };
}
export const quotesService = {
async list(params: {
status?: QuoteStatus[];
customerAccountId?: number;
q?: string;
from?: string;
to?: string;
sort?: QuoteSort;
page?: number;
pageSize?: number;
} = {}): Promise<QuoteListResponse> {
const { data } = await api.get('/admin/quotes', {
params: {
...params,
status: params.status?.join(','),
},
});
return data.data || data;
},
async get(id: number): Promise<QuoteWithLineItems> {
const { data } = await api.get(`/admin/quotes/${id}`);
return data.data || data;
},
async create(payload: QuoteCreatePayload): Promise<QuoteWithLineItems> {
const { data } = await api.post('/admin/quotes', payload);
return data.data || data;
},
async update(id: number, payload: Partial<QuoteCreatePayload>): Promise<QuoteWithLineItems> {
const { data } = await api.put(`/admin/quotes/${id}`, payload);
return data.data || data;
},
async send(id: number): Promise<{ sent: true; token: string }> {
const { data } = await api.post(`/admin/quotes/${id}/send`);
return data.data || data;
},
async duplicate(id: number): Promise<{ id: number }> {
const { data } = await api.post(`/admin/quotes/${id}/duplicate`);
return data.data || data;
},
/** Admin accept-on-behalf — flips the quote to `accepted` without
* going through the customer's public response page. Used for
* phone-call workflows where the customer verbally agrees. */
async acceptOnBehalf(id: number): Promise<{ status: string; lockedAt: string }> {
const { data } = await api.post(`/admin/quotes/${id}/accept`);
return data.data || data;
},
async convert(id: number): Promise<{ eventId: number; alreadyConverted: boolean }> {
const { data } = await api.post(`/admin/quotes/${id}/convert`);
return data.data || data;
},
/** Convert the quote directly into invoice(s) without creating an event. */
async convertToInvoice(id: number): Promise<{ installmentsCreated: number }> {
const { data } = await api.post(`/admin/quotes/${id}/convert-to-invoice`);
return data.data || data;
},
/** Convert the quote into a fresh draft contract (#contracts feature).
* Leaves the quote in 'accepted' status; the contract becomes the
* active deliverable. After the customer + admin both sign, the
* contract detail page exposes its own convert-to-event /
* convert-to-invoice buttons that re-enter the quote conversion
* path via the contract's source_quote_id. */
async convertToContract(id: number): Promise<{ contractId: number; alreadyConverted: boolean }> {
const { data } = await api.post(`/admin/quotes/${id}/convert-to-contract`);
return data.data || data;
},
/** Returns a blob URL the editor can `window.open()` straight into a tab. */
async pdfUrl(id: number): Promise<string> {
const res = await api.get(`/admin/quotes/${id}/pdf`, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async previewPdfUrl(payload: QuoteCreatePayload): Promise<string> {
const res = await api.post('/admin/quotes/preview', payload, { responseType: 'blob' });
return URL.createObjectURL(res.data);
},
async listLineItemPresets(): Promise<{ presets: LineItemPreset[] }> {
const { data } = await api.get('/admin/quotes/presets/line-items');
return data.data || data;
},
async createLineItemPreset(payload: Partial<LineItemPreset> & { name: string }): Promise<{ preset: LineItemPreset }> {
const { data } = await api.post('/admin/quotes/presets/line-items', payload);
return data.data || data;
},
async listPaymentTermTemplates(): Promise<{ templates: PaymentTermTemplate[] }> {
const { data } = await api.get('/admin/quotes/presets/payment-terms');
return data.data || data;
},
async createPaymentTermTemplate(payload: Omit<PaymentTermTemplate, 'id' | 'isSystem'>): Promise<{ template: PaymentTermTemplate }> {
const { data } = await api.post('/admin/quotes/presets/payment-terms', payload);
return data.data || data;
},
async updatePaymentTermTemplate(id: number, payload: Partial<PaymentTermTemplate>): Promise<{ template: PaymentTermTemplate }> {
const { data } = await api.put(`/admin/quotes/presets/payment-terms/${id}`, payload);
return data.data || data;
},
async deletePaymentTermTemplate(id: number): Promise<{ deleted: true }> {
const { data } = await api.delete(`/admin/quotes/presets/payment-terms/${id}`);
return data.data || data;
},
// Split payment-term templates (migration 124).
async listPaymentNetDaysTemplates(): Promise<{ templates: PaymentNetDaysTemplate[] }> {
const { data } = await api.get('/admin/quotes/presets/payment-net-days');
return data.data || data;
},
async listPaymentTimingTemplates(): Promise<{ templates: PaymentTimingTemplate[] }> {
const { data } = await api.get('/admin/quotes/presets/payment-timing');
return data.data || data;
},
};
// -------------------------------------------------------------------
// Public (no-auth) — accept / decline page
// -------------------------------------------------------------------
export interface PublicQuoteView {
quoteNumber: string;
status: QuoteStatus;
language: string;
currency: string;
issueDate: string;
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
eventTimeStart: string | null;
eventTimeEnd: string | null;
introText: string | null;
outroText: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
shippingAmountMinor: number;
totalAmountMinor: number;
respondedAt: string | null;
responseLockedAt: string | null;
canRespond: boolean;
lineItems: Array<{
position: number;
quantity: number;
description: string;
unitPriceMinor: number;
discountPercent: number;
lineTotalMinor: number;
}>;
/** Terms of Service block driven by the global `crm_quotes_tos_*`
* settings. When `required` is true, the public page must show a
* checkbox the customer ticks before Accept can fire. The text +
* url are optional content the admin curates in CRM Settings. */
tos?: {
required: boolean;
text: string;
url: string;
acceptedAt: string | null;
};
recipient: { displayName: string; email: string; companyName: string | null } | null;
issuer: {
companyName: string;
email: string;
website: string;
footerLine: string;
/** Absolute or /uploads/-prefixed URL set by the public route. */
logoUrl?: string | null;
} | null;
}
export const publicQuotesService = {
async get(token: string): Promise<{ quote: PublicQuoteView }> {
const { data } = await api.get(`/public/quotes/${token}`);
return data.data || data;
},
async respond(
token: string,
action: 'accept' | 'decline',
options: { tosAccepted?: boolean } = {},
): Promise<{ status: QuoteStatus; lockedAt: string }> {
const { data } = await api.post(`/public/quotes/${token}/respond`, {
action,
tosAccepted: options.tosAccepted,
});
return data.data || data;
},
};
+23 -1
View File
@@ -14,7 +14,16 @@ export interface BrandingSettings {
favicon_url?: string;
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
logo_max_height?: number;
logo_position?: 'left' | 'center' | 'right';
/**
* Logo placement.
* - 'left' | 'center' | 'right': position inside the gallery header bar.
* - 'sidepanel': moved out of the gallery header into the admin
* sidebar's brand row (replaces the "PicPeak Admin" text; switches
* to the favicon when the sidebar is collapsed). The gallery
* silently falls back to 'left' for its own rendering since it has
* no sidepanel.
*/
logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
@@ -166,6 +175,19 @@ export const settingsService = {
return response.data;
},
// Get a subset of settings by key. Use this when a caller knows
// exactly which keys it needs — saves transferring (and JSON-parsing)
// the ~100-row dict. The backend accepts a comma-separated list and
// returns the same shape as getAllSettings(), just narrower.
async getSettings(keys: string[]): Promise<Record<string, any>> {
if (!keys || keys.length === 0) return {};
const response = await api.get<Record<string, any>>(
'/admin/settings',
{ params: { keys: keys.join(',') } },
);
return response.data;
},
// Get settings by type
async getSettingsByType(type: 'branding' | 'theme' | 'general'): Promise<Record<string, any>> {
const response = await api.get<Record<string, any>>(`/admin/settings/${type}`);
+107
View File
@@ -0,0 +1,107 @@
/**
* Admin → Tax Report API client. Hits /api/admin/tax-report/*.
*
* Three endpoints, one query-string contract:
* from, to YYYY-MM-DD (inclusive)
* currency ISO 4217 alpha-3 (uppercased server-side)
* locale optional, defaults to the business profile default
*/
import { api } from '../config/api';
export interface TaxReportRow {
id: number;
invoiceNumber: string;
issueDate: string;
currency: string;
status: string;
isCancelled: boolean;
/** Document kind discriminator (migration 114). Drives the "Storno"
* badge on rows where kind='storno'. */
kind: 'invoice' | 'storno';
/** True when this invoice was created via Cancel & reissue
* (replaces_invoice_id is set on a non-cancelled row). Drives the
* "Reissue" badge. */
isReissue: boolean;
/** Replacement invoice number when this cancelled row was reissued. */
replacedByInvoiceNumber: string | null;
/** Aggregated from `invoice_payment_log` — true when any payment for
* this invoice was recorded as Skonto-applied (migration 126). */
skontoApplied: boolean;
/** Sum of discount in minor units across all Skonto-applied payments
* on this invoice. 0 when `skontoApplied` is false. */
skontoAmountMinor: number;
vatRate: number;
customerLabel: string;
eventName: string;
/** Minor units (cents/Rappen). */
netMinor: number;
vatMinor: number;
totalMinor: number;
}
export interface TaxReportBucket {
vatRate: number;
netMinor: number;
vatMinor: number;
totalMinor: number;
}
export interface TaxReport {
rows: TaxReportRow[];
totalsByVatRate: TaxReportBucket[];
grandTotalNet: number;
grandTotalVat: number;
grandTotal: number;
cancelledCount: number;
currency: string;
period: { from: string; to: string };
}
export interface TaxReportParams {
from: string;
to: string;
currency: string;
locale?: string;
}
function buildQueryString(params: TaxReportParams): string {
const usp = new URLSearchParams({
from: params.from,
to: params.to,
currency: params.currency,
});
if (params.locale) usp.set('locale', params.locale);
return usp.toString();
}
export const taxReportService = {
async getReport(params: TaxReportParams): Promise<TaxReport> {
const res = await api.get<{ report: TaxReport }>(
`/admin/tax-report?${buildQueryString(params)}`,
);
return res.data.report;
},
/**
* Trigger a browser download for the PDF. Returns the blob URL the
* caller can either assign to `window.location` or open in a new tab.
* The caller is responsible for revoking the URL when done.
*/
async downloadPdfUrl(params: TaxReportParams): Promise<{ url: string; filename: string }> {
const res = await api.get(`/admin/tax-report/pdf?${buildQueryString(params)}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(res.data);
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.pdf`;
return { url, filename };
},
async downloadCsvUrl(params: TaxReportParams): Promise<{ url: string; filename: string }> {
const res = await api.get(`/admin/tax-report/csv?${buildQueryString(params)}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(res.data);
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.csv`;
return { url, filename };
},
};