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:
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user