Merge origin/beta into feat/accounting-inbound-invoices

Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
This commit is contained in:
Luca
2026-06-15 16:37:23 +02:00
46 changed files with 2556 additions and 96 deletions
@@ -109,6 +109,8 @@ export interface ContractSummary {
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
/** Migration 121 — Project Overview link (null when unlinked). */
projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -194,6 +196,8 @@ export interface ContractCreatePayload {
outroText?: string | null;
issueDate?: string;
validUntil?: string;
/** Migration 121 — optional link to a Project Overview project. */
projectId?: number | null;
}
export interface ContractUpdatePayload {
@@ -211,6 +215,8 @@ export interface ContractUpdatePayload {
* 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 }>;
/** Migration 121 — optional Project Overview link. null clears it. */
projectId?: number | null;
}
export interface ContractBlockCreatePayload {
@@ -451,6 +451,8 @@ export interface HourEntryCreatePayload {
endTime: string; // HH:MM
hourlyRateMinorOverride?: number | null;
description?: string | null;
/** Migration 118 — optional "book to project" link. */
projectId?: number | null;
}
export interface HourEntryUpdatePayload {
@@ -54,7 +54,11 @@ export type FeatureKey =
| 'incomingInvoices'
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
// Separate Accounting sub-feature; requires `accounting`.
| 'expenses';
| 'expenses'
// Projects (migration 120). Admin-only grouping layer above events with the
// 360° Project Overview cockpit + the "book to project" hours control. Off
// by default; gates the CRM → Overview area entirely.
| 'projects';
export type FeatureFlags = Record<FeatureKey, boolean>;
+194
View File
@@ -0,0 +1,194 @@
/**
* Admin → Projects API client. Hits /api/admin/projects/*.
*
* Projects are the admin-only grouping layer above events (Model A). The
* cockpit "overview" rolls up the per-event/per-customer documents. Mirrors
* the contracts/bills service shape: `data.data || data` unwrap.
*/
import { api } from '../config/api';
export type ProjectStatus = 'active' | 'archived' | string;
/** Rolled-up project value: newest stage wins per deal (invoice > quote;
* contracts carry no total), cumulative across events, split by currency. */
export interface ProjectValuation {
byCurrency: Array<{ currency: string; totalMinor: number; paidMinor: number }>;
}
export interface ProjectSummary {
id: number;
name: string;
customerAccountId: number | null;
customerEmail: string | null;
status: ProjectStatus;
eventCount?: number;
valuation?: ProjectValuation;
createdAt: string | null;
updatedAt: string | null;
}
export interface ProjectEvent {
id: number;
event_name: string;
event_date: string | null;
slug: string;
is_active: boolean | number;
is_draft: boolean | number;
expires_at: string | null;
is_archived: boolean | number;
}
export interface ProjectEmail {
id: number;
recipient: string;
type: string;
status: string;
queuedAt: string | null;
sentAt: string | null;
error: string | null;
eventId: number | null;
/** true = exact HTML stored at send time; false = preview re-rendered. */
stored: boolean;
}
export interface ProjectInvoice {
id: number;
invoice_number: string;
status: string;
kind: string | null;
issue_date: string | null;
due_date: string | null;
total_amount_minor: number;
paid_amount_minor: number | null;
paid_at: string | null;
currency: string;
event_id: number | null;
deal_uuid: string | null;
}
export interface ProjectQuote {
id: number;
quote_number: string;
status: string;
issue_date: string | null;
valid_until: string | null;
total_amount_minor: number;
currency: string;
deal_uuid: string | null;
}
export interface ProjectContract {
id: number;
contract_number: string;
status: string;
issue_date: string | null;
signed_by_customer_at: string | null;
deal_uuid: string | null;
}
export interface ProjectHourEntry {
id: number;
entry_date: string | null;
duration_minutes: number;
description: string | null;
status: string | null;
invoice_id: number | null;
}
export interface ProjectMilestone {
kind: 'quote' | 'contract' | 'gallery' | 'invoice';
id?: number;
label: string;
date: string | null;
}
export interface ProjectOverview {
project: ProjectSummary;
events: ProjectEvent[];
emails: ProjectEmail[];
quotes: ProjectQuote[];
contracts: ProjectContract[];
invoices: ProjectInvoice[];
hours: { entries: ProjectHourEntry[]; totalMinutes: number };
milestones: ProjectMilestone[];
valuation: ProjectValuation;
}
export interface EmailPreview {
id: number;
recipient: string;
type: string;
status: string;
available: boolean;
/** true = exact bytes stored at send time; false = re-rendered from the
* current template (approximation for emails sent before capture). */
exact: boolean;
html: string | null;
}
export const projectsService = {
async list(params: { q?: string; status?: string } = {}): Promise<ProjectSummary[]> {
const { data } = await api.get('/admin/projects', { params });
const body = data.data || data;
return body.projects || [];
},
async get(id: number): Promise<ProjectSummary> {
const { data } = await api.get(`/admin/projects/${id}`);
const body = data.data || data;
return body.project;
},
async create(payload: { name: string; customerAccountId?: number | null }): Promise<ProjectSummary> {
const { data } = await api.post('/admin/projects', payload);
const body = data.data || data;
return body.project;
},
async update(
id: number,
payload: { name?: string; customerAccountId?: number | null; status?: string },
): Promise<ProjectSummary> {
const { data } = await api.put(`/admin/projects/${id}`, payload);
const body = data.data || data;
return body.project;
},
async overview(id: number): Promise<ProjectOverview> {
const { data } = await api.get(`/admin/projects/${id}/overview`);
return (data.data || data) as ProjectOverview;
},
async assignEvent(projectId: number, eventId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/events`, { eventId });
},
async assignQuote(projectId: number, quoteId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/quotes`, { quoteId });
},
async assignContract(projectId: number, contractId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/contracts`, { contractId });
},
async emailPreview(emailId: number): Promise<EmailPreview> {
const { data } = await api.get(`/admin/projects/email/${emailId}/preview`);
return (data.data || data) as EmailPreview;
},
async resendEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/resend`);
},
async cancelEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/cancel`);
},
async retryEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/retry`);
},
async sendEmailNow(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/send-now`);
},
};
+5
View File
@@ -43,6 +43,8 @@ export interface QuoteSummary {
* doc — contract, invoices, Storni — that shares this deal. */
dealUuid: string | null;
customerAccountId: number;
/** Migration 121 — Project Overview link (null when unlinked). */
projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -195,6 +197,9 @@ export interface QuoteCreatePayload {
internalNotes?: string;
ccPdfEmail?: string;
businessBankAccountId?: number;
/** Migration 121 — optional link to a Project Overview project.
* null clears the link; undefined leaves it unchanged. */
projectId?: number | null;
lineItems: QuoteLineItem[];
}