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:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -185,7 +185,8 @@ export const ProjectCockpitPage: React.FC = () => {
|
||||
if (isLoading) return <Loading />;
|
||||
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 (
|
||||
<div>
|
||||
@@ -216,9 +217,26 @@ export const ProjectCockpitPage: React.FC = () => {
|
||||
{t('projects.totalHours', '{{hours}} logged', { hours: minutesToHours(hours.totalMinutes) })}
|
||||
</p>
|
||||
</div>
|
||||
{editName === null && (
|
||||
<Button variant="outline" onClick={() => setEditName(project.name)}>{t('projects.rename', 'Rename')}</Button>
|
||||
)}
|
||||
<div className="flex items-start gap-4">
|
||||
{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>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
<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 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.updated', 'Updated')}</th>
|
||||
</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 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 font-medium text-neutral-900 dark:text-neutral-100">{formatValuation(p)}</td>
|
||||
<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">
|
||||
{p.status}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user