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 ; if (!data) return
{t('projects.notFound', 'Project not found')}
; - const { project, milestones, hours } = data; + const { project, milestones, hours, valuation } = data; + const valueBuckets = valuation?.byCurrency?.filter((b) => b.totalMinor !== 0 || b.paidMinor !== 0) || []; return (
@@ -216,9 +217,26 @@ export const ProjectCockpitPage: React.FC = () => { {t('projects.totalHours', '{{hours}} logged', { hours: minutesToHours(hours.totalMinutes) })}

- {editName === null && ( - - )} +
+ {valueBuckets.length > 0 && ( +
+
{t('projects.value.label', 'Project value')}
+ {valueBuckets.map((b) => ( +
+ {formatMoneyMinor(b.totalMinor, b.currency)} +
+ ))} + {valueBuckets.some((b) => b.paidMinor !== 0) && ( +
+ {t('projects.value.paid', 'paid')}: {valueBuckets.map((b) => formatMoneyMinor(b.paidMinor, b.currency)).join(' · ')} +
+ )} +
+ )} + {editName === null && ( + + )} +
diff --git a/frontend/src/pages/admin/projects/ProjectsListPage.tsx b/frontend/src/pages/admin/projects/ProjectsListPage.tsx index 2b827612..1f6b8e74 100644 --- a/frontend/src/pages/admin/projects/ProjectsListPage.tsx +++ b/frontend/src/pages/admin/projects/ProjectsListPage.tsx @@ -15,6 +15,15 @@ import { Plus, Search, FolderKanban } from 'lucide-react'; import { Button, Card, Input, Loading } from '../../../components/common'; import { projectsService, type ProjectSummary } from '../../../services/projects.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { formatMoneyMinor } from '../../../utils/money'; + +/** Render a project's rolled-up value (newest stage per deal, cumulative), + * one entry per currency. Empty → em dash. */ +function formatValuation(p: ProjectSummary): string { + const buckets = p.valuation?.byCurrency?.filter((b) => b.totalMinor !== 0) || []; + if (buckets.length === 0) return '—'; + return buckets.map((b) => formatMoneyMinor(b.totalMinor, b.currency)).join(' · '); +} export const ProjectsListPage: React.FC = () => { const { t } = useTranslation(); @@ -109,6 +118,7 @@ export const ProjectsListPage: React.FC = () => { {t('projects.col.name', 'Project')} {t('projects.col.customer', 'Customer')} {t('projects.col.events', 'Events')} + {t('projects.col.value', 'Value')} {t('projects.col.status', 'Status')} {t('projects.col.updated', 'Updated')} @@ -123,6 +133,7 @@ export const ProjectsListPage: React.FC = () => { {p.name} {p.customerEmail || '—'} {p.eventCount ?? 0} + {formatValuation(p)} {p.status} diff --git a/frontend/src/services/projects.service.ts b/frontend/src/services/projects.service.ts index 6ac64b7d..ab7def13 100644 --- a/frontend/src/services/projects.service.ts +++ b/frontend/src/services/projects.service.ts @@ -9,6 +9,12 @@ 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; @@ -16,6 +22,7 @@ export interface ProjectSummary { customerEmail: string | null; status: ProjectStatus; eventCount?: number; + valuation?: ProjectValuation; createdAt: string | null; updatedAt: string | null; } @@ -101,6 +108,7 @@ export interface ProjectOverview { invoices: ProjectInvoice[]; hours: { entries: ProjectHourEntry[]; totalMinutes: number }; milestones: ProjectMilestone[]; + valuation: ProjectValuation; } export interface EmailPreview {