From c0b6d14d08755f23665eaa8d52dd6a250d6c9074 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:28:52 +0200 Subject: [PATCH] fix(projects): clickable milestones/feed, email rollup by customer, PG amount coercion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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.) --- backend/src/services/projectService.js | 22 ++++++---- .../admin/projects/ProjectCockpitPage.tsx | 41 +++++++++++++++---- frontend/src/services/projects.service.ts | 1 + 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js index c18273eb..38c260ea 100644 --- a/backend/src/services/projectService.js +++ b/backend/src/services/projectService.js @@ -229,11 +229,17 @@ async function getProjectOverview(id, perms = {}) { const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } }; - // Emails (by event) โ€” newest first. rendered_html presence flagged, body - // itself fetched lazily by the preview endpoint. - if (eventIds.length) { + // Emails โ€” newest first. rendered_html presence flagged, body fetched + // lazily by the preview endpoint. Gallery/event mails carry event_id; + // 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') - .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') .orderBy('created_at', 'desc') .limit(200); @@ -289,13 +295,13 @@ async function getProjectOverview(id, perms = {}) { // Timeline milestones (latest of each kind that exists), each dated. const milestones = []; 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]; - 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); - 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]; - 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; // Rolled-up project value (newest stage wins per deal, cumulative). diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx index 366ae4a2..b24c6d61 100644 --- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx +++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx @@ -7,7 +7,7 @@ * gallery and logged hour that rolls up to the project. Admin-only. */ 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; @@ -20,6 +20,7 @@ import { projectsService, type ProjectOverview, type EmailPreview, + type ProjectMilestone, } from '../../../services/projects.service'; import { eventsService } from '../../../services/events.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; @@ -35,10 +36,24 @@ interface FeedItem { subtitle?: string; amount?: string; status?: string; + href?: string | null; emailId?: number; 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> = { email: Mail, quote: FileText, @@ -58,6 +73,7 @@ export const ProjectCockpitPage: React.FC = () => { const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const projectId = id ? parseInt(id, 10) : null; + const navigate = useNavigate(); const qc = useQueryClient(); const { format, formatTime } = useLocalizedDate(); @@ -144,28 +160,30 @@ export const ProjectCockpitPage: React.FC = () => { items.push({ key: `quote-${q.id}`, kind: 'quote', date: q.issue_date, 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) { items.push({ key: `contract-${c.id}`, kind: 'contract', date: c.issue_date, 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) { items.push({ key: `invoice-${inv.id}`, kind: 'invoice', date: inv.issue_date, 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) { items.push({ key: `gallery-${ev.id}`, kind: 'gallery', date: ev.event_date, 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) { @@ -290,8 +308,13 @@ export const ProjectCockpitPage: React.FC = () => {
{milestones.map((m, i) => { const Icon = KIND_ICON[m.kind] || FileText; + const href = hrefFor(m.kind, m.id); return ( -
+
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' : ''}`} + >
{m.label}
@@ -314,7 +337,11 @@ export const ProjectCockpitPage: React.FC = () => { {feed.map((item) => { const Icon = KIND_ICON[item.kind]; return ( -
  • +
  • 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' : ''}`} + >
    diff --git a/frontend/src/services/projects.service.ts b/frontend/src/services/projects.service.ts index ab7def13..fb604e29 100644 --- a/frontend/src/services/projects.service.ts +++ b/frontend/src/services/projects.service.ts @@ -95,6 +95,7 @@ export interface ProjectHourEntry { export interface ProjectMilestone { kind: 'quote' | 'contract' | 'gallery' | 'invoice'; + id?: number; label: string; date: string | null; }