fix(projects): clickable milestones/feed, email rollup by customer, PG amount coercion

- Milestones + feed rows now link to the document (quote/contract/bill
  detail, event for galleries); hours have no page so stay non-clickable.
- Email rollup also matches the project customer's address — quote/invoice/
  contract mails are queued with event_id=null, so the by-event scope alone
  showed none (hence 'no email preview'). Now they appear with preview.
- Feed amounts coerce total_amount_minor with Number(): Postgres returns
  bigint as a string, which formatMoneyMinor's Number.isFinite check
  rejected and rendered as CHF 0.00. (computeValuation already coerced.)
This commit is contained in:
Luca
2026-06-06 22:28:52 +02:00
parent 7ca243780a
commit c0b6d14d08
3 changed files with 49 additions and 15 deletions
+14 -8
View File
@@ -229,11 +229,17 @@ async function getProjectOverview(id, perms = {}) {
const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } }; const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } };
// Emails (by event) — newest first. rendered_html presence flagged, body // Emails — newest first. rendered_html presence flagged, body fetched
// itself fetched lazily by the preview endpoint. // lazily by the preview endpoint. Gallery/event mails carry event_id;
if (eventIds.length) { // CRM document mails (quote_sent, invoice_*, contract_*) are queued with
// event_id=null, so we also match the project customer's email address.
const customerEmail = project.customerEmail || null;
if (eventIds.length || customerEmail) {
const emails = await db('email_queue') const emails = await db('email_queue')
.whereIn('event_id', eventIds) .where(function () {
if (eventIds.length) this.whereIn('event_id', eventIds);
if (customerEmail) this.orWhere('recipient_email', customerEmail);
})
.select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id') .select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id')
.orderBy('created_at', 'desc') .orderBy('created_at', 'desc')
.limit(200); .limit(200);
@@ -289,13 +295,13 @@ async function getProjectOverview(id, perms = {}) {
// Timeline milestones (latest of each kind that exists), each dated. // Timeline milestones (latest of each kind that exists), each dated.
const milestones = []; const milestones = [];
const firstQuote = out.quotes[out.quotes.length - 1]; const firstQuote = out.quotes[out.quotes.length - 1];
if (firstQuote) milestones.push({ kind: 'quote', label: firstQuote.quote_number, date: firstQuote.issue_date }); if (firstQuote) milestones.push({ kind: 'quote', id: firstQuote.id, label: firstQuote.quote_number, date: firstQuote.issue_date });
const firstContract = out.contracts[out.contracts.length - 1]; const firstContract = out.contracts[out.contracts.length - 1];
if (firstContract) milestones.push({ kind: 'contract', label: firstContract.contract_number, date: firstContract.issue_date }); if (firstContract) milestones.push({ kind: 'contract', id: firstContract.id, label: firstContract.contract_number, date: firstContract.issue_date });
const pubEvent = events.find((e) => e.is_active && !e.is_draft); const pubEvent = events.find((e) => e.is_active && !e.is_draft);
if (pubEvent) milestones.push({ kind: 'gallery', label: pubEvent.event_name, date: pubEvent.event_date }); if (pubEvent) milestones.push({ kind: 'gallery', id: pubEvent.id, label: pubEvent.event_name, date: pubEvent.event_date });
const firstInvoice = out.invoices[out.invoices.length - 1]; const firstInvoice = out.invoices[out.invoices.length - 1];
if (firstInvoice) milestones.push({ kind: 'invoice', label: firstInvoice.invoice_number, date: firstInvoice.issue_date }); if (firstInvoice) milestones.push({ kind: 'invoice', id: firstInvoice.id, label: firstInvoice.invoice_number, date: firstInvoice.issue_date });
out.milestones = milestones; out.milestones = milestones;
// Rolled-up project value (newest stage wins per deal, cumulative). // Rolled-up project value (newest stage wins per deal, cumulative).
@@ -7,7 +7,7 @@
* gallery and logged hour that rolls up to the project. Admin-only. * gallery and logged hour that rolls up to the project. Admin-only.
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -20,6 +20,7 @@ import {
projectsService, projectsService,
type ProjectOverview, type ProjectOverview,
type EmailPreview, type EmailPreview,
type ProjectMilestone,
} from '../../../services/projects.service'; } from '../../../services/projects.service';
import { eventsService } from '../../../services/events.service'; import { eventsService } from '../../../services/events.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
@@ -35,10 +36,24 @@ interface FeedItem {
subtitle?: string; subtitle?: string;
amount?: string; amount?: string;
status?: string; status?: string;
href?: string | null;
emailId?: number; emailId?: number;
emailStatus?: string; emailStatus?: string;
} }
/** Detail-page route for a clickable document, or null when there isn't one
* (hours have no standalone page; emails open the preview instead). */
function hrefFor(kind: FeedKind | ProjectMilestone['kind'], id?: number): string | null {
if (id == null) return null;
switch (kind) {
case 'quote': return `/admin/clients/quotes/${id}`;
case 'contract': return `/admin/clients/contracts/${id}`;
case 'invoice': return `/admin/clients/bills/${id}`;
case 'gallery': return `/admin/events/${id}`;
default: return null;
}
}
const KIND_ICON: Record<FeedKind, React.ComponentType<{ className?: string }>> = { const KIND_ICON: Record<FeedKind, React.ComponentType<{ className?: string }>> = {
email: Mail, email: Mail,
quote: FileText, quote: FileText,
@@ -58,6 +73,7 @@ export const ProjectCockpitPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const projectId = id ? parseInt(id, 10) : null; const projectId = id ? parseInt(id, 10) : null;
const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const { format, formatTime } = useLocalizedDate(); const { format, formatTime } = useLocalizedDate();
@@ -144,28 +160,30 @@ export const ProjectCockpitPage: React.FC = () => {
items.push({ items.push({
key: `quote-${q.id}`, kind: 'quote', date: q.issue_date, key: `quote-${q.id}`, kind: 'quote', date: q.issue_date,
title: t('projects.feed.quote', 'Quote') + ` ${q.quote_number}`, title: t('projects.feed.quote', 'Quote') + ` ${q.quote_number}`,
status: q.status, amount: formatMoneyMinor(q.total_amount_minor, q.currency), status: q.status, amount: formatMoneyMinor(Number(q.total_amount_minor), q.currency),
href: hrefFor('quote', q.id),
}); });
} }
for (const c of data.contracts) { for (const c of data.contracts) {
items.push({ items.push({
key: `contract-${c.id}`, kind: 'contract', date: c.issue_date, key: `contract-${c.id}`, kind: 'contract', date: c.issue_date,
title: t('projects.feed.contract', 'Contract') + ` ${c.contract_number}`, title: t('projects.feed.contract', 'Contract') + ` ${c.contract_number}`,
status: c.status, status: c.status, href: hrefFor('contract', c.id),
}); });
} }
for (const inv of data.invoices) { for (const inv of data.invoices) {
items.push({ items.push({
key: `invoice-${inv.id}`, kind: 'invoice', date: inv.issue_date, key: `invoice-${inv.id}`, kind: 'invoice', date: inv.issue_date,
title: t('projects.feed.invoice', 'Invoice') + ` ${inv.invoice_number}`, title: t('projects.feed.invoice', 'Invoice') + ` ${inv.invoice_number}`,
status: inv.status, amount: formatMoneyMinor(inv.total_amount_minor, inv.currency), status: inv.status, amount: formatMoneyMinor(Number(inv.total_amount_minor), inv.currency),
href: hrefFor('invoice', inv.id),
}); });
} }
for (const ev of data.events) { for (const ev of data.events) {
items.push({ items.push({
key: `gallery-${ev.id}`, kind: 'gallery', date: ev.event_date, key: `gallery-${ev.id}`, kind: 'gallery', date: ev.event_date,
title: t('projects.feed.gallery', 'Gallery') + ` · ${ev.event_name}`, title: t('projects.feed.gallery', 'Gallery') + ` · ${ev.event_name}`,
subtitle: ev.slug, subtitle: ev.slug, href: hrefFor('gallery', ev.id),
}); });
} }
for (const h of data.hours.entries) { for (const h of data.hours.entries) {
@@ -290,8 +308,13 @@ export const ProjectCockpitPage: React.FC = () => {
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{milestones.map((m, i) => { {milestones.map((m, i) => {
const Icon = KIND_ICON[m.kind] || FileText; const Icon = KIND_ICON[m.kind] || FileText;
const href = hrefFor(m.kind, m.id);
return ( return (
<div key={`${m.kind}-${i}`} className="flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2"> <div
key={`${m.kind}-${i}`}
onClick={href ? () => navigate(href) : undefined}
className={`flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2 ${href ? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/60' : ''}`}
>
<Icon className="w-4 h-4 text-neutral-500" /> <Icon className="w-4 h-4 text-neutral-500" />
<div> <div>
<div className="text-xs font-medium text-neutral-900 dark:text-neutral-100">{m.label}</div> <div className="text-xs font-medium text-neutral-900 dark:text-neutral-100">{m.label}</div>
@@ -314,7 +337,11 @@ export const ProjectCockpitPage: React.FC = () => {
{feed.map((item) => { {feed.map((item) => {
const Icon = KIND_ICON[item.kind]; const Icon = KIND_ICON[item.kind];
return ( return (
<li key={item.key} className="flex items-start gap-3 rounded-lg border border-neutral-100 dark:border-neutral-800 px-3 py-2"> <li
key={item.key}
onClick={item.href ? () => navigate(item.href as string) : undefined}
className={`flex items-start gap-3 rounded-lg border border-neutral-100 dark:border-neutral-800 px-3 py-2 ${item.href ? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/60' : ''}`}
>
<Icon className="w-4 h-4 mt-0.5 text-neutral-500 flex-shrink-0" /> <Icon className="w-4 h-4 mt-0.5 text-neutral-500 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -95,6 +95,7 @@ export interface ProjectHourEntry {
export interface ProjectMilestone { export interface ProjectMilestone {
kind: 'quote' | 'contract' | 'gallery' | 'invoice'; kind: 'quote' | 'contract' | 'gallery' | 'invoice';
id?: number;
label: string; label: string;
date: string | null; date: string | null;
} }