diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js
index e8d3ddbc..b5ae2dee 100644
--- a/backend/src/routes/adminProjects.js
+++ b/backend/src/routes/adminProjects.js
@@ -33,9 +33,16 @@ router.use(requireProjectsFlag);
// List
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
+ // Value rollup mirrors the cockpit's per-doc gating so the list never
+ // shows figures the admin lacks permission to see.
+ const perms = {
+ bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
+ quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
+ };
const projects = await projectService.listProjects({
search: req.query.q || '',
status: req.query.status || null,
+ perms,
});
return successResponse(res, { projects });
}));
diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js
index eca06f49..c18273eb 100644
--- a/backend/src/services/projectService.js
+++ b/backend/src/services/projectService.js
@@ -31,8 +31,10 @@ function transformProject(p) {
};
}
-/** List projects with customer email + event count. */
-async function listProjects({ search = '', status = null } = {}) {
+/** List projects with customer email + event count + rolled-up value.
+ * `perms` gates which document types feed the value (matches the cockpit):
+ * invoices need bills.view, quotes need quotes.view. */
+async function listProjects({ search = '', status = null, perms = {} } = {}) {
let q = db('projects')
.leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
.select(
@@ -49,7 +51,50 @@ async function listProjects({ search = '', status = null } = {}) {
});
}
const rows = await q;
- return rows.map(transformProject);
+ const projects = rows.map(transformProject);
+ await attachValuations(projects, perms);
+ return projects;
+}
+
+/**
+ * Compute + attach `valuation` to each listed project in two bulk queries
+ * (not per-project), then run the shared newest-wins-per-deal helper. Mutates
+ * the passed array. Documents the admin can't see (per perms) are excluded,
+ * so the value never leaks figures the admin lacks permission for.
+ */
+async function attachValuations(projects, perms = {}) {
+ if (!projects.length) return;
+ const projectIds = projects.map((p) => p.id);
+
+ // Invoices roll up by event → project_id; quotes by quotes.project_id.
+ let invoices = [];
+ if (perms.bills !== false) {
+ invoices = await db('invoices as inv')
+ .join('events as e', 'e.id', 'inv.event_id')
+ .whereIn('e.project_id', projectIds)
+ .select('e.project_id as project_id', 'inv.id', 'inv.deal_uuid',
+ 'inv.total_amount_minor', 'inv.paid_amount_minor', 'inv.currency');
+ }
+ let quotes = [];
+ if (perms.quotes !== false && await hasColumnCached('quotes', 'project_id')) {
+ quotes = await db('quotes')
+ .whereIn('project_id', projectIds)
+ .select('project_id', 'id', 'deal_uuid', 'total_amount_minor', 'currency', 'issue_date');
+ }
+
+ const invByProject = new Map();
+ for (const inv of invoices) {
+ const list = invByProject.get(inv.project_id) || [];
+ list.push(inv); invByProject.set(inv.project_id, list);
+ }
+ const quoteByProject = new Map();
+ for (const qt of quotes) {
+ const list = quoteByProject.get(qt.project_id) || [];
+ list.push(qt); quoteByProject.set(qt.project_id, list);
+ }
+ for (const p of projects) {
+ p.valuation = computeValuation(invByProject.get(p.id) || [], quoteByProject.get(p.id) || []);
+ }
}
async function getProjectById(id) {
@@ -113,6 +158,61 @@ async function assignDocument(table, projectId, documentId) {
const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId);
const assignContract = (projectId, contractId) => assignDocument('contracts', projectId, contractId);
+/**
+ * Project valuation — "newest stage wins per deal, cumulative across events".
+ *
+ * Each deal (deal_uuid lineage: quote → contract → invoice) contributes ONE
+ * figure: the invoice total when the deal has reached invoicing (installments
+ * summed; storno rows net out a cancelled invoice via their negative totals),
+ * otherwise the newest quote's total. Contracts carry no monetary total in
+ * picpeak, so they never contribute a number — the "newest" of the three is
+ * therefore always the invoice when present, else the quote. Documents with
+ * no deal_uuid each count as their own standalone deal. Totals are kept per
+ * currency so a mixed-currency project stays correct.
+ *
+ * @param {Array} invoices rows with deal_uuid, total_amount_minor, paid_amount_minor, currency
+ * @param {Array} quotes rows with deal_uuid, total_amount_minor, currency, issue_date
+ * @returns {{ byCurrency: Array<{currency:string,totalMinor:number,paidMinor:number}> }}
+ */
+function computeValuation(invoices = [], quotes = []) {
+ const deals = new Map();
+ const get = (key, currency) => {
+ let d = deals.get(key);
+ if (!d) { d = { currency, invoiceMinor: 0, paidMinor: 0, hasInvoice: false, quoteMinor: 0, quoteDate: null }; deals.set(key, d); }
+ return d;
+ };
+ for (const inv of invoices) {
+ const d = get(inv.deal_uuid || `i-${inv.id}`, inv.currency || 'CHF');
+ d.hasInvoice = true;
+ d.invoiceMinor += Number(inv.total_amount_minor || 0);
+ d.paidMinor += Number(inv.paid_amount_minor || 0);
+ d.currency = inv.currency || d.currency;
+ }
+ for (const q of quotes) {
+ const d = get(q.deal_uuid || `q-${q.id}`, q.currency || 'CHF');
+ const qd = q.issue_date ? new Date(q.issue_date).getTime() : 0;
+ if (d.quoteDate === null || qd >= d.quoteDate) {
+ d.quoteMinor = Number(q.total_amount_minor || 0);
+ d.quoteDate = qd;
+ if (!d.hasInvoice) d.currency = q.currency || d.currency;
+ }
+ }
+ const byCurrency = new Map();
+ for (const d of deals.values()) {
+ const value = d.hasInvoice ? d.invoiceMinor : d.quoteMinor;
+ const cur = d.currency || 'CHF';
+ const b = byCurrency.get(cur) || { totalMinor: 0, paidMinor: 0 };
+ b.totalMinor += value;
+ b.paidMinor += d.paidMinor;
+ byCurrency.set(cur, b);
+ }
+ return {
+ byCurrency: Array.from(byCurrency.entries()).map(([currency, v]) => ({
+ currency, totalMinor: v.totalMinor, paidMinor: v.paidMinor,
+ })),
+ };
+}
+
/**
* Full overview aggregation for the cockpit. Returns the project, its events,
* and the rolled-up emails / quotes / contracts / invoices / hours + a
@@ -198,6 +298,9 @@ async function getProjectOverview(id, perms = {}) {
if (firstInvoice) milestones.push({ kind: 'invoice', label: firstInvoice.invoice_number, date: firstInvoice.issue_date });
out.milestones = milestones;
+ // Rolled-up project value (newest stage wins per deal, cumulative).
+ out.valuation = computeValuation(out.invoices, out.quotes);
+
return out;
}
@@ -273,6 +376,7 @@ module.exports = {
assignEvent,
assignQuote,
assignContract,
+ computeValuation,
getProjectOverview,
getEmailPreview,
resendEmail,
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 8a1a0ed1..44123a08 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3403,6 +3403,7 @@
"name": "Projekt",
"customer": "Kunde",
"events": "Events",
+ "value": "Wert",
"status": "Status",
"updated": "Aktualisiert"
},
@@ -3410,6 +3411,10 @@
"label": "Projekt",
"none": "Kein Projekt"
},
+ "value": {
+ "label": "Projektwert",
+ "paid": "bezahlt"
+ },
"events": {
"title": "Events",
"none": "Diesem Projekt sind noch keine Events zugeordnet.",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 9baa4d41..d2e1c44d 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3403,6 +3403,7 @@
"name": "Project",
"customer": "Customer",
"events": "Events",
+ "value": "Value",
"status": "Status",
"updated": "Updated"
},
@@ -3410,6 +3411,10 @@
"label": "Project",
"none": "No project"
},
+ "value": {
+ "label": "Project value",
+ "paid": "paid"
+ },
"events": {
"title": "Events",
"none": "No events grouped under this project yet.",
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index bbfc65c0..366ae4a2 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -185,7 +185,8 @@ export const ProjectCockpitPage: React.FC = () => {
if (isLoading) return