feat(projects): rolled-up project value (newest stage wins per deal, cumulative)
- computeValuation helper: per deal_uuid, the invoice total (installments summed, storno netted) wins over the quote; contracts carry no total so never contribute. Summed across the project's events, split by currency. - Value column on the Project Overview list + a value/paid block in the cockpit header. Both gated by bills.view/quotes.view so no figure leaks. - listProjects computes all values in two bulk queries (not per-project). - en + de i18n; six unit assertions cover the rule's edge cases.
This commit is contained in:
@@ -33,9 +33,16 @@ router.use(requireProjectsFlag);
|
|||||||
|
|
||||||
// List
|
// List
|
||||||
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
|
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({
|
const projects = await projectService.listProjects({
|
||||||
search: req.query.q || '',
|
search: req.query.q || '',
|
||||||
status: req.query.status || null,
|
status: req.query.status || null,
|
||||||
|
perms,
|
||||||
});
|
});
|
||||||
return successResponse(res, { projects });
|
return successResponse(res, { projects });
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ function transformProject(p) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List projects with customer email + event count. */
|
/** List projects with customer email + event count + rolled-up value.
|
||||||
async function listProjects({ search = '', status = null } = {}) {
|
* `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')
|
let q = db('projects')
|
||||||
.leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
|
.leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
|
||||||
.select(
|
.select(
|
||||||
@@ -49,7 +51,50 @@ async function listProjects({ search = '', status = null } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const rows = await q;
|
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) {
|
async function getProjectById(id) {
|
||||||
@@ -113,6 +158,61 @@ async function assignDocument(table, projectId, documentId) {
|
|||||||
const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId);
|
const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId);
|
||||||
const assignContract = (projectId, contractId) => assignDocument('contracts', projectId, contractId);
|
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,
|
* Full overview aggregation for the cockpit. Returns the project, its events,
|
||||||
* and the rolled-up emails / quotes / contracts / invoices / hours + a
|
* 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 });
|
if (firstInvoice) milestones.push({ kind: 'invoice', label: firstInvoice.invoice_number, date: firstInvoice.issue_date });
|
||||||
out.milestones = milestones;
|
out.milestones = milestones;
|
||||||
|
|
||||||
|
// Rolled-up project value (newest stage wins per deal, cumulative).
|
||||||
|
out.valuation = computeValuation(out.invoices, out.quotes);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +376,7 @@ module.exports = {
|
|||||||
assignEvent,
|
assignEvent,
|
||||||
assignQuote,
|
assignQuote,
|
||||||
assignContract,
|
assignContract,
|
||||||
|
computeValuation,
|
||||||
getProjectOverview,
|
getProjectOverview,
|
||||||
getEmailPreview,
|
getEmailPreview,
|
||||||
resendEmail,
|
resendEmail,
|
||||||
|
|||||||
@@ -3403,6 +3403,7 @@
|
|||||||
"name": "Projekt",
|
"name": "Projekt",
|
||||||
"customer": "Kunde",
|
"customer": "Kunde",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
|
"value": "Wert",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"updated": "Aktualisiert"
|
"updated": "Aktualisiert"
|
||||||
},
|
},
|
||||||
@@ -3410,6 +3411,10 @@
|
|||||||
"label": "Projekt",
|
"label": "Projekt",
|
||||||
"none": "Kein Projekt"
|
"none": "Kein Projekt"
|
||||||
},
|
},
|
||||||
|
"value": {
|
||||||
|
"label": "Projektwert",
|
||||||
|
"paid": "bezahlt"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Events",
|
"title": "Events",
|
||||||
"none": "Diesem Projekt sind noch keine Events zugeordnet.",
|
"none": "Diesem Projekt sind noch keine Events zugeordnet.",
|
||||||
|
|||||||
@@ -3403,6 +3403,7 @@
|
|||||||
"name": "Project",
|
"name": "Project",
|
||||||
"customer": "Customer",
|
"customer": "Customer",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
|
"value": "Value",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"updated": "Updated"
|
"updated": "Updated"
|
||||||
},
|
},
|
||||||
@@ -3410,6 +3411,10 @@
|
|||||||
"label": "Project",
|
"label": "Project",
|
||||||
"none": "No project"
|
"none": "No project"
|
||||||
},
|
},
|
||||||
|
"value": {
|
||||||
|
"label": "Project value",
|
||||||
|
"paid": "paid"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Events",
|
"title": "Events",
|
||||||
"none": "No events grouped under this project yet.",
|
"none": "No events grouped under this project yet.",
|
||||||
|
|||||||
@@ -185,7 +185,8 @@ export const ProjectCockpitPage: React.FC = () => {
|
|||||||
if (isLoading) return <Loading />;
|
if (isLoading) return <Loading />;
|
||||||
if (!data) return <div className="p-6 text-neutral-500">{t('projects.notFound', 'Project not found')}</div>;
|
if (!data) return <div className="p-6 text-neutral-500">{t('projects.notFound', 'Project not found')}</div>;
|
||||||
|
|
||||||
const { project, milestones, hours } = data;
|
const { project, milestones, hours, valuation } = data;
|
||||||
|
const valueBuckets = valuation?.byCurrency?.filter((b) => b.totalMinor !== 0 || b.paidMinor !== 0) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -216,9 +217,26 @@ export const ProjectCockpitPage: React.FC = () => {
|
|||||||
{t('projects.totalHours', '{{hours}} logged', { hours: minutesToHours(hours.totalMinutes) })}
|
{t('projects.totalHours', '{{hours}} logged', { hours: minutesToHours(hours.totalMinutes) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{editName === null && (
|
<div className="flex items-start gap-4">
|
||||||
<Button variant="outline" onClick={() => setEditName(project.name)}>{t('projects.rename', 'Rename')}</Button>
|
{valueBuckets.length > 0 && (
|
||||||
)}
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">{t('projects.value.label', 'Project value')}</div>
|
||||||
|
{valueBuckets.map((b) => (
|
||||||
|
<div key={b.currency} className="text-lg font-bold text-neutral-900 dark:text-neutral-100 tabular-nums">
|
||||||
|
{formatMoneyMinor(b.totalMinor, b.currency)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{valueBuckets.some((b) => b.paidMinor !== 0) && (
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('projects.value.paid', 'paid')}: {valueBuckets.map((b) => formatMoneyMinor(b.paidMinor, b.currency)).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editName === null && (
|
||||||
|
<Button variant="outline" onClick={() => setEditName(project.name)}>{t('projects.rename', 'Rename')}</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ import { Plus, Search, FolderKanban } from 'lucide-react';
|
|||||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||||
import { projectsService, type ProjectSummary } from '../../../services/projects.service';
|
import { projectsService, type ProjectSummary } from '../../../services/projects.service';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
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 = () => {
|
export const ProjectsListPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -109,6 +118,7 @@ export const ProjectsListPage: React.FC = () => {
|
|||||||
<th className="px-4 py-2 font-medium">{t('projects.col.name', 'Project')}</th>
|
<th className="px-4 py-2 font-medium">{t('projects.col.name', 'Project')}</th>
|
||||||
<th className="px-4 py-2 font-medium">{t('projects.col.customer', 'Customer')}</th>
|
<th className="px-4 py-2 font-medium">{t('projects.col.customer', 'Customer')}</th>
|
||||||
<th className="px-4 py-2 font-medium text-right">{t('projects.col.events', 'Events')}</th>
|
<th className="px-4 py-2 font-medium text-right">{t('projects.col.events', 'Events')}</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">{t('projects.col.value', 'Value')}</th>
|
||||||
<th className="px-4 py-2 font-medium">{t('projects.col.status', 'Status')}</th>
|
<th className="px-4 py-2 font-medium">{t('projects.col.status', 'Status')}</th>
|
||||||
<th className="px-4 py-2 font-medium">{t('projects.col.updated', 'Updated')}</th>
|
<th className="px-4 py-2 font-medium">{t('projects.col.updated', 'Updated')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -123,6 +133,7 @@ export const ProjectsListPage: React.FC = () => {
|
|||||||
<td className="px-4 py-2 font-medium text-neutral-900 dark:text-neutral-100">{p.name}</td>
|
<td className="px-4 py-2 font-medium text-neutral-900 dark:text-neutral-100">{p.name}</td>
|
||||||
<td className="px-4 py-2 text-neutral-600 dark:text-neutral-400">{p.customerEmail || '—'}</td>
|
<td className="px-4 py-2 text-neutral-600 dark:text-neutral-400">{p.customerEmail || '—'}</td>
|
||||||
<td className="px-4 py-2 text-right tabular-nums">{p.eventCount ?? 0}</td>
|
<td className="px-4 py-2 text-right tabular-nums">{p.eventCount ?? 0}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{formatValuation(p)}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200">
|
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200">
|
||||||
{p.status}
|
{p.status}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import { api } from '../config/api';
|
|||||||
|
|
||||||
export type ProjectStatus = 'active' | 'archived' | string;
|
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 {
|
export interface ProjectSummary {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,6 +22,7 @@ export interface ProjectSummary {
|
|||||||
customerEmail: string | null;
|
customerEmail: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
eventCount?: number;
|
eventCount?: number;
|
||||||
|
valuation?: ProjectValuation;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
@@ -101,6 +108,7 @@ export interface ProjectOverview {
|
|||||||
invoices: ProjectInvoice[];
|
invoices: ProjectInvoice[];
|
||||||
hours: { entries: ProjectHourEntry[]; totalMinutes: number };
|
hours: { entries: ProjectHourEntry[]; totalMinutes: number };
|
||||||
milestones: ProjectMilestone[];
|
milestones: ProjectMilestone[];
|
||||||
|
valuation: ProjectValuation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailPreview {
|
export interface EmailPreview {
|
||||||
|
|||||||
Reference in New Issue
Block a user