= ({
'On delivery — admin releases manually. Switch to advanced to change.')}
) : (
- {
- const next = e.target.value;
+ onChange={(next) => {
if (!next) return;
const offset = daysBetween(todayIso(), next);
update(idx, { trigger: 'fixed_date', offset_days: offset });
diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx
index 81cfd8d3..228f5281 100644
--- a/frontend/src/pages/admin/bills/BillEditorPage.tsx
+++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx
@@ -566,8 +566,31 @@ export const BillEditorPage: React.FC = () => {
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
- setScheduledSendAt(e.target.value)} />
+
+
{t('bills.field.scheduledSendAt', 'Scheduled send (optional)')}
+ {/* Localized date + time (honours general_date_format +
+ general_time_format) instead of a native datetime-local, which
+ renders in the browser locale (US date + 12h). Recombined into
+ the "YYYY-MM-DDTHH:MM" the payload + scheduler expect. */}
+
+ {
+ if (!iso) { setScheduledSendAt(''); return; }
+ const time = scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : '09:00';
+ setScheduledSendAt(`${iso}T${time}`);
+ }}
+ />
+ = 16 ? scheduledSendAt.slice(11, 16) : ''}
+ onChange={(hhmm) => {
+ const date = scheduledSendAt ? scheduledSendAt.slice(0, 10) : '';
+ if (!date) return;
+ setScheduledSendAt(`${date}T${hhmm || '09:00'}`);
+ }}
+ />
+
+
{t('bills.field.qrFormat', 'Payment QR format')}
{
+ try {
+ // @ts-expect-error supportedValuesOf is ES2022, not yet in all TS lib defs
+ return Intl.supportedValuesOf('timeZone') as string[];
+ } catch {
+ return ['UTC', 'Europe/Vaduz', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Paris', 'Europe/London'];
+ }
+})();
+
export const SettingsBusinessProfilePage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -117,17 +129,26 @@ export const SettingsBusinessProfilePage: React.FC = () => {
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
setProfile({ ...profile, defaultLocale: e.target.value })} />
- {/* Migration 137 — IANA timezone string for the admin calendar.
- Free-text; backend caps at 64 chars. When blank the calendar
- UI falls back to the browser's `Intl.DateTimeFormat()
- .resolvedOptions().timeZone`. */}
- setProfile({ ...profile, timezone: e.target.value || null })}
- />
+ {/* Migration 137 — IANA timezone for the admin calendar + the
+ scheduled-email business-hours snapping. Dropdown of the full
+ IANA list; blank = fall back to the server/browser tz. */}
+
+
+ {t('businessProfile.field.timezone', 'Timezone (IANA)')}
+
+ setProfile({ ...profile, timezone: e.target.value || null })}
+ className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
+ >
+
+ {t('businessProfile.field.timezoneSystemDefault', 'System default')} ({Intl.DateTimeFormat().resolvedOptions().timeZone})
+
+ {IANA_TIMEZONES.map((tz) => (
+ {tz}
+ ))}
+
+
setProfile({ ...profile, vatLabel: e.target.value })} />
Date: Sat, 6 Jun 2026 03:06:21 +0200
Subject: [PATCH 04/29] =?UTF-8?q?feat(crm):=20Project=20Overview=20phase?=
=?UTF-8?q?=201=20=E2=80=94=20projects=20schema?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Data model for the admin-only Project Overview cockpit (Model A — projects
group events; money docs stay per-event and roll up).
- migration 117: projects table (name, customer_account_id nullable, status)
+ events.project_id FK; backfill one auto-project per existing event (1:1
default, customer = the event's single assignment when unambiguous), admins
relink freely afterward. 1 project : N events.
- migration 118: customer_hour_entries.project_id (book hours to a project).
- migration 119: email_queue.rendered_html (store actual sent HTML for the
cockpit's email preview).
All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full
migration boot + backfill on a temp DB.
---
backend/migrations/core/117_add_projects.js | 85 +++++++++++++++++++
.../118_add_project_id_to_hour_entries.js | 30 +++++++
.../119_add_rendered_html_to_email_queue.js | 31 +++++++
3 files changed, 146 insertions(+)
create mode 100644 backend/migrations/core/117_add_projects.js
create mode 100644 backend/migrations/core/118_add_project_id_to_hour_entries.js
create mode 100644 backend/migrations/core/119_add_rendered_html_to_email_queue.js
diff --git a/backend/migrations/core/117_add_projects.js b/backend/migrations/core/117_add_projects.js
new file mode 100644
index 00000000..98417e27
--- /dev/null
+++ b/backend/migrations/core/117_add_projects.js
@@ -0,0 +1,85 @@
+/**
+ * Migration: Projects — an admin-only grouping layer ABOVE events
+ * (Project Overview cockpit, Model A).
+ *
+ * A project groups one OR MORE events of (usually) one customer; all the
+ * money documents (quotes/contracts/invoices) stay attached to their EVENT
+ * and the project simply rolls them up. Customers never see projects.
+ *
+ * projects id, name, customer_account_id (nullable), status,
+ * timestamps.
+ * events.project_id FK → projects (nullable, SET NULL on project delete).
+ *
+ * Backfill: every existing event gets its OWN auto-created project (the
+ * 1:1 default) so nothing is unassigned; admins then relink freely (group
+ * several events under one project, move events between projects). The
+ * auto-project's customer = the event's single assigned customer when there
+ * is exactly one, else NULL (admin sets it later). Cardinality is 1:N — the
+ * per-event auto-project is only the starting point, never a hard rule.
+ *
+ * Idempotent: table + column guarded; backfill touches only events whose
+ * project_id is still NULL, so a re-run is a no-op.
+ */
+
+exports.up = async function (knex) {
+ // 1. projects table
+ if (!(await knex.schema.hasTable('projects'))) {
+ await knex.schema.createTable('projects', (table) => {
+ table.increments('id').primary();
+ table.string('name', 255).notNullable();
+ // Nullable: a multi-customer or not-yet-assigned project has no single
+ // customer. SET NULL so erasing a customer doesn't delete the project.
+ table.integer('customer_account_id').unsigned()
+ .references('id').inTable('customer_accounts').onDelete('SET NULL');
+ table.string('status', 24).notNullable().defaultTo('active');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+ table.index(['customer_account_id']);
+ });
+ }
+
+ // 2. events.project_id
+ if ((await knex.schema.hasTable('events')) && !(await knex.schema.hasColumn('events', 'project_id'))) {
+ await knex.schema.alterTable('events', (table) => {
+ table.integer('project_id').unsigned()
+ .references('id').inTable('projects').onDelete('SET NULL');
+ table.index(['project_id']);
+ });
+ }
+
+ // 3. Backfill one auto-project per still-unassigned event.
+ if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) {
+ const events = await knex('events').whereNull('project_id').select('id', 'event_name');
+ const hasAssignments = await knex.schema.hasTable('event_customer_assignments');
+ for (const ev of events) {
+ let customerId = null;
+ if (hasAssignments) {
+ const rows = await knex('event_customer_assignments')
+ .where({ event_id: ev.id })
+ .select('customer_account_id');
+ if (rows.length === 1) customerId = rows[0].customer_account_id;
+ }
+ const name = (ev.event_name && String(ev.event_name).trim()) || `Event ${ev.id}`;
+ const inserted = await knex('projects').insert({
+ name,
+ customer_account_id: customerId,
+ status: 'active',
+ created_at: knex.fn.now(),
+ updated_at: knex.fn.now(),
+ }).returning('id');
+ const projectId = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
+ await knex('events').where({ id: ev.id }).update({ project_id: projectId });
+ }
+ }
+};
+
+exports.down = async function (knex) {
+ if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) {
+ await knex.schema.alterTable('events', (table) => {
+ table.dropColumn('project_id');
+ });
+ }
+ if (await knex.schema.hasTable('projects')) {
+ await knex.schema.dropTable('projects');
+ }
+};
diff --git a/backend/migrations/core/118_add_project_id_to_hour_entries.js b/backend/migrations/core/118_add_project_id_to_hour_entries.js
new file mode 100644
index 00000000..457faea8
--- /dev/null
+++ b/backend/migrations/core/118_add_project_id_to_hour_entries.js
@@ -0,0 +1,30 @@
+/**
+ * Migration: book logged hours to a project.
+ *
+ * Adds customer_hour_entries.project_id (nullable FK → projects, SET NULL).
+ * Hours stay primarily customer-scoped; the optional project link powers the
+ * "book to project" checkbox + the Project Overview hours roll-up. Null =
+ * not booked to a project (existing behaviour preserved).
+ *
+ * Idempotent: column guarded by hasColumn.
+ */
+
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('customer_hour_entries'))) return;
+ if (!(await knex.schema.hasColumn('customer_hour_entries', 'project_id'))) {
+ await knex.schema.alterTable('customer_hour_entries', (table) => {
+ table.integer('project_id').unsigned()
+ .references('id').inTable('projects').onDelete('SET NULL');
+ table.index(['project_id']);
+ });
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('customer_hour_entries'))) return;
+ if (await knex.schema.hasColumn('customer_hour_entries', 'project_id')) {
+ await knex.schema.alterTable('customer_hour_entries', (table) => {
+ table.dropColumn('project_id');
+ });
+ }
+};
diff --git a/backend/migrations/core/119_add_rendered_html_to_email_queue.js b/backend/migrations/core/119_add_rendered_html_to_email_queue.js
new file mode 100644
index 00000000..73309d16
--- /dev/null
+++ b/backend/migrations/core/119_add_rendered_html_to_email_queue.js
@@ -0,0 +1,31 @@
+/**
+ * Migration: store the rendered email HTML at send time.
+ *
+ * The Project Overview cockpit previews the ACTUAL email that was sent (not a
+ * re-render from the current template, which may have changed). email_queue
+ * only stored the template variables (email_data), so add a rendered_html
+ * column the sender populates with the final wrapped HTML on dispatch.
+ *
+ * Nullable: rows queued/sent before this column existed have no stored HTML —
+ * the cockpit reconstructs those from email_data with a "reconstructed" note.
+ *
+ * Idempotent: column guarded by hasColumn.
+ */
+
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('email_queue'))) return;
+ if (!(await knex.schema.hasColumn('email_queue', 'rendered_html'))) {
+ await knex.schema.alterTable('email_queue', (table) => {
+ table.text('rendered_html');
+ });
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('email_queue'))) return;
+ if (await knex.schema.hasColumn('email_queue', 'rendered_html')) {
+ await knex.schema.alterTable('email_queue', (table) => {
+ table.dropColumn('rendered_html');
+ });
+ }
+};
From eb263137b98935754155824de2a03848121304b6 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 03:50:55 +0200
Subject: [PATCH 05/29] =?UTF-8?q?feat(crm):=20Project=20Overview=20phase?=
=?UTF-8?q?=202=20=E2=80=94=20project=20service=20+=20routes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend API for the cockpit (admin-only, Model A):
- projectService: list/get/create/update, assignEvent (re-point events.project_id),
getProjectOverview (rollup — invoices/emails/gallery by event, quotes/contracts
by customer since they carry no event_id, hours by project_id, + a milestone
timeline), getEmailPreview (actual sent HTML).
- adminProjects routes (/api/admin/projects): read=events.view, write=events.manage;
the overview gates each money-doc type on the admin's own bills/quotes/contracts
.view permission. Registered in server.js.
All aggregation queries verified against the real schema on a temp DB.
---
backend/server.js | 1 +
backend/src/routes/adminProjects.js | 102 ++++++++++++
backend/src/services/projectService.js | 207 +++++++++++++++++++++++++
3 files changed, 310 insertions(+)
create mode 100644 backend/src/routes/adminProjects.js
create mode 100644 backend/src/services/projectService.js
diff --git a/backend/server.js b/backend/server.js
index bc1427fc..2b19f3bb 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -703,6 +703,7 @@ app.use('/api/admin/business-profile', require('./src/routes/adminBusinessProfil
app.use('/api/admin/quotes', require('./src/routes/adminQuotes'));
app.use('/api/admin/invoices', require('./src/routes/adminInvoices'));
app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
+app.use('/api/admin/projects', require('./src/routes/adminProjects'));
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js
new file mode 100644
index 00000000..ec19d326
--- /dev/null
+++ b/backend/src/routes/adminProjects.js
@@ -0,0 +1,102 @@
+/**
+ * Admin → Projects routes (the admin-only Project Overview cockpit, Model A).
+ *
+ * Mounted at /api/admin/projects. Projects group events; the overview rolls
+ * up the per-event/per-customer documents. Read = `events.view`, write =
+ * `events.manage` (projects are fundamentally an events-grouping concept).
+ * The overview additionally gates each money-doc type on the admin's own
+ * bills/quotes/contracts view permission.
+ */
+
+const express = require('express');
+const { body, param } = require('express-validator');
+const { adminAuth } = require('../middleware/auth');
+const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
+const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
+const projectService = require('../services/projectService');
+
+const router = express.Router();
+router.use(adminAuth);
+
+// List
+router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
+ const projects = await projectService.listProjects({
+ search: req.query.q || '',
+ status: req.query.status || null,
+ });
+ return successResponse(res, { projects });
+}));
+
+// Create
+router.post('/',
+ requirePermission('events.manage'),
+ [body('name').isString().trim().isLength({ min: 1, max: 255 }), body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const project = await projectService.createProject(
+ { name: req.body.name, customerAccountId: req.body.customerAccountId || null },
+ req.admin.id,
+ );
+ return successResponse(res, { project }, 201, 'Project created');
+ }),
+);
+
+// Detail
+router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const project = await projectService.getProjectById(parseInt(req.params.id, 10));
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+ return successResponse(res, { project });
+}));
+
+// Update
+router.put('/:id',
+ requirePermission('events.manage'),
+ [
+ param('id').isInt({ min: 1 }),
+ body('name').optional().isString().trim().isLength({ min: 1, max: 255 }),
+ body('customerAccountId').optional({ values: 'null' }).isInt({ min: 1 }),
+ body('status').optional().isString().isLength({ max: 24 }),
+ ],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const project = await projectService.updateProject(parseInt(req.params.id, 10), {
+ name: req.body.name,
+ customerAccountId: req.body.customerAccountId,
+ status: req.body.status,
+ });
+ return successResponse(res, { project }, 200, 'Project updated');
+ }),
+);
+
+// Attach an event to the project
+router.post('/:id/events',
+ requirePermission('events.manage'),
+ [param('id').isInt({ min: 1 }), body('eventId').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const result = await projectService.assignEvent(parseInt(req.params.id, 10), parseInt(req.body.eventId, 10));
+ return successResponse(res, result, 200, 'Event attached to project');
+ }),
+);
+
+// The cockpit aggregation — doc types gated on the admin's own permissions
+router.get('/:id/overview', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const perms = {
+ bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
+ quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
+ contracts: await userHasAnyPermission(req.admin.id, ['contracts.view']),
+ };
+ const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms);
+ return successResponse(res, overview);
+}));
+
+// Email preview — the ACTUAL sent HTML (or null for pre-rendered_html rows)
+router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const preview = await projectService.getEmailPreview(parseInt(req.params.emailId, 10));
+ return successResponse(res, preview);
+}));
+
+module.exports = router;
diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js
new file mode 100644
index 00000000..54db20d7
--- /dev/null
+++ b/backend/src/services/projectService.js
@@ -0,0 +1,207 @@
+/**
+ * projectService — the admin-only "Project Overview" grouping layer (Model A).
+ *
+ * A project groups 1..N events. Money documents stay attached to their EVENT
+ * (or, for quotes/contracts which carry no event_id, to the CUSTOMER) and the
+ * project rolls them up for the cockpit. Customers never see projects.
+ *
+ * Rollup scoping (v1):
+ * - invoices / emails / galleries → by the project's EVENTS (event_id).
+ * - hours → by customer_hour_entries.project_id.
+ * - quotes / contracts → by the project's customer_account_id
+ * (they have no event_id; see migration 107). Empty when the project has
+ * no single customer set.
+ */
+
+const { db } = require('../database/db');
+const { AppError } = require('../utils/errors');
+
+function transformProject(p) {
+ if (!p) return null;
+ return {
+ id: p.id,
+ name: p.name,
+ customerAccountId: p.customer_account_id || null,
+ customerEmail: p.customer_email || null,
+ status: p.status,
+ eventCount: p.event_count != null ? Number(p.event_count) : undefined,
+ createdAt: p.created_at,
+ updatedAt: p.updated_at,
+ };
+}
+
+/** List projects with customer email + event count. */
+async function listProjects({ search = '', status = null } = {}) {
+ let q = db('projects')
+ .leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
+ .select(
+ 'projects.*',
+ 'customer_accounts.email as customer_email',
+ db('events').count('* as c').whereRaw('events.project_id = projects.id').as('event_count'),
+ )
+ .orderBy('projects.updated_at', 'desc');
+ if (status) q = q.where('projects.status', status);
+ if (search) {
+ q = q.where(function () {
+ this.where('projects.name', 'like', `%${search}%`)
+ .orWhere('customer_accounts.email', 'like', `%${search}%`);
+ });
+ }
+ const rows = await q;
+ return rows.map(transformProject);
+}
+
+async function getProjectById(id) {
+ const row = await db('projects')
+ .leftJoin('customer_accounts', 'customer_accounts.id', 'projects.customer_account_id')
+ .select('projects.*', 'customer_accounts.email as customer_email')
+ .where('projects.id', id)
+ .first();
+ return transformProject(row);
+}
+
+async function createProject({ name, customerAccountId = null }, adminId) {
+ if (!name || !String(name).trim()) throw new AppError('Project name is required', 400);
+ const inserted = await db('projects').insert({
+ name: String(name).trim(),
+ customer_account_id: customerAccountId || null,
+ status: 'active',
+ created_at: new Date(),
+ updated_at: new Date(),
+ }).returning('id');
+ const id = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
+ return getProjectById(id);
+}
+
+async function updateProject(id, { name, customerAccountId, status }) {
+ const existing = await db('projects').where({ id }).first();
+ if (!existing) throw new AppError('Project not found', 404);
+ const patch = { updated_at: new Date() };
+ if (name !== undefined) patch.name = String(name).trim();
+ if (customerAccountId !== undefined) patch.customer_account_id = customerAccountId || null;
+ if (status !== undefined) patch.status = status;
+ await db('projects').where({ id }).update(patch);
+ return getProjectById(id);
+}
+
+/** Attach an event to a project (re-points events.project_id). */
+async function assignEvent(projectId, eventId) {
+ const project = await db('projects').where({ id: projectId }).first();
+ if (!project) throw new AppError('Project not found', 404);
+ const event = await db('events').where({ id: eventId }).first();
+ if (!event) throw new AppError('Event not found', 404);
+ await db('events').where({ id: eventId }).update({ project_id: projectId });
+ return { projectId, eventId };
+}
+
+/**
+ * Full overview aggregation for the cockpit. Returns the project, its events,
+ * and the rolled-up emails / quotes / contracts / invoices / hours + a
+ * timeline of milestones. `perms` gates which doc types are included.
+ */
+async function getProjectOverview(id, perms = {}) {
+ const project = await getProjectById(id);
+ if (!project) throw new AppError('Project not found', 404);
+
+ const events = await db('events')
+ .where({ project_id: id })
+ .select('id', 'event_name', 'event_date', 'slug', 'is_active', 'is_draft', 'expires_at', 'is_archived');
+ const eventIds = events.map((e) => e.id);
+
+ 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) {
+ const emails = await db('email_queue')
+ .whereIn('event_id', eventIds)
+ .select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id')
+ .orderBy('created_at', 'desc')
+ .limit(200);
+ out.emails = emails.map((e) => ({
+ id: e.id, recipient: e.recipient_email, type: e.email_type, status: e.status,
+ queuedAt: e.created_at, sentAt: e.sent_at, error: e.error_message, eventId: e.event_id,
+ }));
+ }
+
+ // Invoices (by event) incl. storno.
+ if (eventIds.length && perms.bills !== false) {
+ out.invoices = await db('invoices')
+ .whereIn('event_id', eventIds)
+ .select('id', 'invoice_number', 'status', 'kind', 'issue_date', 'due_date',
+ 'total_amount_minor', 'paid_amount_minor', 'paid_at', 'currency', 'event_id', 'deal_uuid')
+ .orderBy('issue_date', 'desc');
+ }
+
+ // Quotes / contracts (by customer — no event_id on those tables).
+ if (project.customerAccountId) {
+ if (perms.quotes !== false) {
+ out.quotes = await db('quotes')
+ .where({ customer_account_id: project.customerAccountId })
+ .select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid')
+ .orderBy('issue_date', 'desc');
+ }
+ if (perms.contracts !== false) {
+ out.contracts = await db('contracts')
+ .where({ customer_account_id: project.customerAccountId })
+ .select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid')
+ .orderBy('issue_date', 'desc');
+ }
+ }
+
+ // Hours (by project_id) — individual entries + total.
+ const hours = await db('customer_hour_entries')
+ .where({ project_id: id })
+ .select('id', 'entry_date', 'duration_minutes', 'description', 'status', 'invoice_id')
+ .orderBy('entry_date', 'desc');
+ out.hours = {
+ entries: hours,
+ totalMinutes: hours.reduce((s, h) => s + Number(h.duration_minutes || 0), 0),
+ };
+
+ // 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 });
+ const firstContract = out.contracts[out.contracts.length - 1];
+ if (firstContract) milestones.push({ kind: 'contract', 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 });
+ const firstInvoice = out.invoices[out.invoices.length - 1];
+ if (firstInvoice) milestones.push({ kind: 'invoice', label: firstInvoice.invoice_number, date: firstInvoice.issue_date });
+ out.milestones = milestones;
+
+ return out;
+}
+
+/**
+ * The ACTUAL sent HTML for an email_queue row (cockpit preview). Rows sent
+ * before the rendered_html column existed have none → `available:false`, the
+ * frontend then shows a "preview not stored" note rather than a stale
+ * re-render.
+ */
+async function getEmailPreview(emailId) {
+ const row = await db('email_queue')
+ .where({ id: emailId })
+ .select('id', 'recipient_email', 'email_type', 'status', 'rendered_html')
+ .first();
+ if (!row) throw new AppError('Email not found', 404);
+ return {
+ id: row.id,
+ recipient: row.recipient_email,
+ type: row.email_type,
+ status: row.status,
+ available: !!row.rendered_html,
+ html: row.rendered_html || null,
+ };
+}
+
+module.exports = {
+ listProjects,
+ getProjectById,
+ createProject,
+ updateProject,
+ assignEvent,
+ getProjectOverview,
+ getEmailPreview,
+};
From 874c91f944d8397edaf9f48091bab3bf5cfc30e1 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 04:00:16 +0200
Subject: [PATCH 06/29] =?UTF-8?q?feat(crm):=20Project=20Overview=20phase?=
=?UTF-8?q?=203=20=E2=80=94=20persist=20sent=20email=20HTML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
processEmailQueue now stores the actual rendered HTML in email_queue
.rendered_html on a successful send (sendTemplateEmail returns it). Guarded
by hasColumnCached so installs without migration 119 just skip it; never
blocks the send. Powers the cockpit's exact-sent email preview.
---
backend/src/services/emailProcessor.js | 24 ++++++++++++++++--------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js
index 58462bfa..2539dcc5 100644
--- a/backend/src/services/emailProcessor.js
+++ b/backend/src/services/emailProcessor.js
@@ -763,7 +763,9 @@ async function sendTemplateEmail(to, templateKey, variables) {
});
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
- return { success: true, messageId: info.messageId, language };
+ // Return the rendered HTML so the queue processor can persist the ACTUAL
+ // sent body (email_queue.rendered_html) for the Project Overview preview.
+ return { success: true, messageId: info.messageId, language, html: htmlBody };
} catch (error) {
logger.error('Error sending template email:', error);
throw error;
@@ -839,19 +841,25 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
? JSON.parse(email.email_data || '{}')
: email.email_data || {};
- await sendTemplateEmail(
+ const sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
);
-
- // Mark as sent
+
+ // Mark as sent, persisting the actual rendered HTML for the Project
+ // Overview email preview (guarded — older installs without migration
+ // 119 just skip it).
+ const sentUpdate = { status: 'sent', sent_at: new Date() };
+ try {
+ const { hasColumnCached } = require('../utils/schemaCache');
+ if (sendResult && sendResult.html && await hasColumnCached('email_queue', 'rendered_html')) {
+ sentUpdate.rendered_html = sendResult.html;
+ }
+ } catch (_) { /* best-effort — never block the send on the preview */ }
await db('email_queue')
.where('id', email.id)
- .update({
- status: 'sent',
- sent_at: new Date()
- });
+ .update(sentUpdate);
result.sent += 1;
logger.info(`Email ${email.id} sent successfully`);
From 1bf0b34ea5e280770e0262660586f345860b0325 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 12:59:57 +0200
Subject: [PATCH 07/29] feat(projects): gate Project Overview behind a projects
feature flag + cockpit email actions
- Migration 120 seeds the projects flag (default OFF), idempotent.
- Backend feature-flags whitelist + DEFAULT_FLAGS + clients derivation.
- adminProjects routes 403 PROJECTS_DISABLED when the flag is off.
- projectService email actions (resend/cancel/retry/send-now) + routes.
- Frontend flag type, DEFAULT_FLAGS, Features tab card (en+de).
---
.../core/120_seed_projects_feature_flag.js | 24 ++++++++++
backend/src/routes/adminFeatureFlags.js | 7 +++
backend/src/routes/adminProjects.js | 25 ++++++++++
backend/src/services/projectService.js | 46 +++++++++++++++++++
frontend/src/contexts/FeatureFlagsContext.tsx | 4 ++
.../features/settings/tabs/FeaturesTab.tsx | 15 ++++++
frontend/src/i18n/locales/de.json | 5 ++
frontend/src/i18n/locales/en.json | 5 ++
frontend/src/services/featureFlags.service.ts | 6 ++-
9 files changed, 136 insertions(+), 1 deletion(-)
create mode 100644 backend/migrations/core/120_seed_projects_feature_flag.js
diff --git a/backend/migrations/core/120_seed_projects_feature_flag.js b/backend/migrations/core/120_seed_projects_feature_flag.js
new file mode 100644
index 00000000..2673ddd9
--- /dev/null
+++ b/backend/migrations/core/120_seed_projects_feature_flag.js
@@ -0,0 +1,24 @@
+/**
+ * Migration: seed the `projects` feature flag (default OFF).
+ *
+ * Gates the admin-only Project Overview cockpit + the "book to project" hours
+ * control. Off by default so existing installs don't suddenly surface a new
+ * top-level CRM area — the admin opts in under Settings → Features, exactly
+ * like bills/quotes/contracts/hours.
+ *
+ * Idempotent: inserts only when the row is missing (migration 088 already
+ * seeded the original flag set on fresh installs and won't re-run).
+ */
+
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+ const existing = await knex('feature_flags').where({ key: 'projects' }).first();
+ if (!existing) {
+ await knex('feature_flags').insert({ key: 'projects', value: false });
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+ await knex('feature_flags').where({ key: 'projects' }).del();
+};
diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js
index f76b2ceb..04575e1d 100644
--- a/backend/src/routes/adminFeatureFlags.js
+++ b/backend/src/routes/adminFeatureFlags.js
@@ -62,6 +62,10 @@ const KNOWN_FLAGS = [
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
// lawyer review before sending. See docs/crm-disclaimers.md.
'contracts',
+ // Projects (migration 120). Admin-only grouping layer above events +
+ // the Project Overview cockpit ("book to project" hours control, 360°
+ // rollup feed). Lights up the Clients section. Customers never see it.
+ 'projects',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
@@ -84,6 +88,7 @@ const DEFAULT_FLAGS = {
taxReport: false,
hoursLogging: false,
contracts: false,
+ projects: false,
};
async function readAllFlags() {
@@ -123,6 +128,8 @@ function applyDependencyRules(flags) {
|| out.taxReport
|| out.hoursLogging
|| out.contracts
+ // Migration 120 — admin-only Project Overview cockpit lives under Clients.
+ || out.projects
// Migration 137 — admin calendar lights up the Clients section.
// (calendarBooking is gated behind `calendar` so adding the parent
// is sufficient.)
diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js
index ec19d326..180566b4 100644
--- a/backend/src/routes/adminProjects.js
+++ b/backend/src/routes/adminProjects.js
@@ -14,10 +14,23 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission, userHasAnyPermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const projectService = require('../services/projectService');
+const { db } = require('../database/db');
const router = express.Router();
router.use(adminAuth);
+// Projects is feature-flagged like bills/quotes — when off, the whole cockpit
+// (and the "book to project" hours control) is hidden, and the API 403s.
+async function requireProjectsFlag(req, res, next) {
+ try {
+ const row = await db('feature_flags').where({ key: 'projects' }).first();
+ const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
+ if (!enabled) return res.status(403).json({ error: 'Projects feature is disabled', code: 'PROJECTS_DISABLED' });
+ next();
+ } catch (err) { next(err); }
+}
+router.use(requireProjectsFlag);
+
// List
router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => {
const projects = await projectService.listProjects({
@@ -99,4 +112,16 @@ router.get('/email/:emailId/preview', requirePermission('events.view'), [param('
return successResponse(res, preview);
}));
+// Email actions from the feed (need email.send). Resend (sent→fresh copy),
+// Cancel (pending→cancelled), Retry (failed→pending), Send now (flush this one).
+const emailAction = (fn) => handleAsync(async (req, res) => {
+ validateRequest(req);
+ const result = await projectService[fn](parseInt(req.params.emailId, 10));
+ return successResponse(res, result);
+});
+router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('resendEmail'));
+router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('cancelEmail'));
+router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('retryEmail'));
+router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('sendEmailNow'));
+
module.exports = router;
diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js
index 54db20d7..9755e22b 100644
--- a/backend/src/services/projectService.js
+++ b/backend/src/services/projectService.js
@@ -196,6 +196,48 @@ async function getEmailPreview(emailId) {
};
}
+// ── Email actions (from the cockpit feed) ───────────────────────────────
+
+async function resendEmail(emailId) {
+ const row = await db('email_queue').where({ id: emailId }).first();
+ if (!row) throw new AppError('Email not found', 404);
+ const insert = await db('email_queue').insert({
+ recipient_email: row.recipient_email,
+ email_type: row.email_type,
+ email_data: row.email_data,
+ event_id: row.event_id,
+ status: 'pending',
+ retry_count: 0,
+ created_at: new Date(),
+ }).returning('id');
+ const id = (insert[0] && typeof insert[0] === 'object') ? insert[0].id : insert[0];
+ return { id, status: 'pending' };
+}
+
+async function cancelEmail(emailId) {
+ const row = await db('email_queue').where({ id: emailId }).first();
+ if (!row) throw new AppError('Email not found', 404);
+ if (row.status !== 'pending') throw new AppError('Only pending emails can be cancelled', 409);
+ await db('email_queue').where({ id: emailId }).update({ status: 'cancelled' });
+ return { id: emailId, status: 'cancelled' };
+}
+
+async function retryEmail(emailId) {
+ const row = await db('email_queue').where({ id: emailId }).first();
+ if (!row) throw new AppError('Email not found', 404);
+ await db('email_queue').where({ id: emailId })
+ .update({ status: 'pending', retry_count: 0, error_message: null, scheduled_at: null });
+ return { id: emailId, status: 'pending' };
+}
+
+async function sendEmailNow(emailId) {
+ const row = await db('email_queue').where({ id: emailId }).first();
+ if (!row) throw new AppError('Email not found', 404);
+ await db('email_queue').where({ id: emailId }).update({ status: 'pending', scheduled_at: null });
+ const { processEmailQueue } = require('./emailProcessor');
+ return processEmailQueue({ ignoreSchedule: true, limit: 50 });
+}
+
module.exports = {
listProjects,
getProjectById,
@@ -204,4 +246,8 @@ module.exports = {
assignEvent,
getProjectOverview,
getEmailPreview,
+ resendEmail,
+ cancelEmail,
+ retryEmail,
+ sendEmailNow,
};
diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx
index b0da55dc..36b56fd7 100644
--- a/frontend/src/contexts/FeatureFlagsContext.tsx
+++ b/frontend/src/contexts/FeatureFlagsContext.tsx
@@ -45,6 +45,10 @@ export const DEFAULT_FLAGS: FeatureFlags = {
// Settings → Features once they've reviewed the seeded block
// library with their lawyer.
contracts: false,
+ // Projects (migration 120). Admin-only grouping layer above events +
+ // the Project Overview cockpit. Off by default — admin opts in under
+ // Settings → Features once they want the CRM → Overview area.
+ projects: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx
index b5cefdc7..0d4fd6bb 100644
--- a/frontend/src/features/settings/tabs/FeaturesTab.tsx
+++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx
@@ -16,6 +16,7 @@ import {
Briefcase,
Wrench,
Calculator,
+ FolderKanban,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -291,6 +292,20 @@ export const FeaturesTab: React.FC = () => {
enabled={staged.hoursLogging}
onToggle={(next) => setFlag('hoursLogging', next)}
/>
+
+ setFlag('projects', next)}
+ />
{/* Insights & Access */}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index a33d2dbd..c955df08 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1666,6 +1666,11 @@
"title": "Stundenerfassung",
"description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.",
"sidebar": "Stunden"
+ },
+ "projects": {
+ "title": "Projekte",
+ "description": "Nur-Admin-Gruppierungsebene über Events. Bündle mehrere Events unter einem Projekt und öffne ein 360°-Projektübersichts-Cockpit — Meilenstein-Zeitleiste plus ein datierter Verlauf aller E-Mails (mit der tatsächlich gesendeten Vorschau + Erneut-senden/Abbrechen/Wiederholen-Aktionen), Angebote, Verträge, Rechnungen, Galerien und erfassten Stunden. Fügt beim Erfassen von Stunden eine „Auf Projekt buchen“-Option hinzu. Kunden sehen Projekte nie.",
+ "sidebar": "Übersicht"
}
},
"customerSurface": {
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 757c2d55..7e8c5a03 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1224,6 +1224,11 @@
"title": "Hours logging",
"description": "Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a \"Create draft invoice\" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.",
"sidebar": "Hours"
+ },
+ "projects": {
+ "title": "Projects",
+ "description": "Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a \"book to project\" control when logging hours. Customers never see projects.",
+ "sidebar": "Overview"
}
},
"customerSurface": {
diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts
index c5015e8f..ad3c95e1 100644
--- a/frontend/src/services/featureFlags.service.ts
+++ b/frontend/src/services/featureFlags.service.ts
@@ -41,7 +41,11 @@ export type FeatureKey =
// upload. Independent of quotes / bills — contracts can be sent on
// their own. Seeded block bodies are examples only; admins must
// have their lawyer review before sending. See docs/crm-disclaimers.md.
- | 'contracts';
+ | 'contracts'
+ // Projects (migration 120). Admin-only grouping layer above events with the
+ // 360° Project Overview cockpit + the "book to project" hours control. Off
+ // by default; gates the CRM → Overview area entirely.
+ | 'projects';
export type FeatureFlags = Record;
From 6420047e7cda046057d27539b85334874081b51d Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 13:05:36 +0200
Subject: [PATCH 08/29] feat(projects): link quotes & contracts to a project
(precise cockpit rollup)
- Migration 121 adds quotes.project_id + contracts.project_id (nullable FK,
index) and backfills the unambiguous single-project-per-customer case.
- projectService rolls quotes/contracts up by project_id, with a
customer-based fallback on pre-121 DBs (hasColumnCached guarded).
- quote/contract create+update accept an optional projectId; detail
transforms surface it for editor prefill.
- POST /projects/:id/quotes and /:id/contracts assign endpoints.
---
.../121_add_project_id_to_quotes_contracts.js | 65 +++++++++++++++++++
backend/src/routes/adminContracts.js | 2 +
backend/src/routes/adminProjects.js | 22 +++++++
backend/src/routes/adminQuotes.js | 4 ++
backend/src/services/contractService.js | 8 +++
backend/src/services/projectService.js | 57 ++++++++++++----
backend/src/services/quoteService.js | 8 +++
7 files changed, 152 insertions(+), 14 deletions(-)
create mode 100644 backend/migrations/core/121_add_project_id_to_quotes_contracts.js
diff --git a/backend/migrations/core/121_add_project_id_to_quotes_contracts.js b/backend/migrations/core/121_add_project_id_to_quotes_contracts.js
new file mode 100644
index 00000000..867dd703
--- /dev/null
+++ b/backend/migrations/core/121_add_project_id_to_quotes_contracts.js
@@ -0,0 +1,65 @@
+/**
+ * Migration: link quotes + contracts to a project.
+ *
+ * Quotes and contracts carry no event_id (see migration 107), so the Project
+ * Overview cockpit originally rolled them up by the project's customer — which
+ * is imprecise once a customer has more than one project. This adds an explicit
+ * `project_id` FK to both tables so the rollup is exact, and the quote/contract
+ * editors get a project picker.
+ *
+ * quotes.project_id FK → projects (nullable, SET NULL on project delete).
+ * contracts.project_id FK → projects (nullable, SET NULL on project delete).
+ *
+ * Backfill: only the unambiguous case. For every customer that owns EXACTLY ONE
+ * project, link that customer's still-unassigned quotes/contracts to it. Customers
+ * with several projects stay unassigned — the admin links them via the picker
+ * (we can't guess which project a document belongs to).
+ *
+ * Idempotent: columns guarded; backfill touches only NULL project_id rows.
+ */
+
+exports.up = async function (knex) {
+ for (const tbl of ['quotes', 'contracts']) {
+ if ((await knex.schema.hasTable(tbl)) && !(await knex.schema.hasColumn(tbl, 'project_id'))) {
+ await knex.schema.alterTable(tbl, (table) => {
+ table.integer('project_id').unsigned()
+ .references('id').inTable('projects').onDelete('SET NULL');
+ table.index(['project_id']);
+ });
+ }
+ }
+
+ if (!(await knex.schema.hasTable('projects'))) return;
+
+ // Customers that own exactly one project → the unambiguous backfill target.
+ const projects = await knex('projects').whereNotNull('customer_account_id').select('id', 'customer_account_id');
+ const byCustomer = new Map();
+ for (const p of projects) {
+ const list = byCustomer.get(p.customer_account_id) || [];
+ list.push(p.id);
+ byCustomer.set(p.customer_account_id, list);
+ }
+
+ for (const [customerId, projectIds] of byCustomer.entries()) {
+ if (projectIds.length !== 1) continue;
+ const projectId = projectIds[0];
+ for (const tbl of ['quotes', 'contracts']) {
+ if (!(await knex.schema.hasTable(tbl))) continue;
+ if (!(await knex.schema.hasColumn(tbl, 'customer_account_id'))) continue;
+ await knex(tbl)
+ .where({ customer_account_id: customerId })
+ .whereNull('project_id')
+ .update({ project_id: projectId });
+ }
+ }
+};
+
+exports.down = async function (knex) {
+ for (const tbl of ['quotes', 'contracts']) {
+ if ((await knex.schema.hasTable(tbl)) && (await knex.schema.hasColumn(tbl, 'project_id'))) {
+ await knex.schema.alterTable(tbl, (table) => {
+ table.dropColumn('project_id');
+ });
+ }
+ }
+};
diff --git a/backend/src/routes/adminContracts.js b/backend/src/routes/adminContracts.js
index b97137e9..d6c3564e 100644
--- a/backend/src/routes/adminContracts.js
+++ b/backend/src/routes/adminContracts.js
@@ -90,6 +90,8 @@ function transformContract(c, inclusions) {
id: c.id,
contractNumber: c.contract_number,
customerAccountId: c.customer_account_id,
+ // Migration 121 — Project Overview link (undefined on pre-121 DBs).
+ projectId: c.project_id ?? null,
customer: {
email: c.customer_email,
displayName: c.customer_display_name,
diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js
index 180566b4..e8d3ddbc 100644
--- a/backend/src/routes/adminProjects.js
+++ b/backend/src/routes/adminProjects.js
@@ -93,6 +93,28 @@ router.post('/:id/events',
}),
);
+// Attach a quote to the project (quotes carry no event_id — migration 121).
+router.post('/:id/quotes',
+ requirePermission('events.manage'),
+ [param('id').isInt({ min: 1 }), body('quoteId').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const result = await projectService.assignQuote(parseInt(req.params.id, 10), parseInt(req.body.quoteId, 10));
+ return successResponse(res, result, 200, 'Quote attached to project');
+ }),
+);
+
+// Attach a contract to the project.
+router.post('/:id/contracts',
+ requirePermission('events.manage'),
+ [param('id').isInt({ min: 1 }), body('contractId').isInt({ min: 1 })],
+ handleAsync(async (req, res) => {
+ validateRequest(req);
+ const result = await projectService.assignContract(parseInt(req.params.id, 10), parseInt(req.body.contractId, 10));
+ return successResponse(res, result, 200, 'Contract attached to project');
+ }),
+);
+
// The cockpit aggregation — doc types gated on the admin's own permissions
router.get('/:id/overview', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req);
diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js
index fd8664b4..a130f4c1 100644
--- a/backend/src/routes/adminQuotes.js
+++ b/backend/src/routes/adminQuotes.js
@@ -63,6 +63,8 @@ function transformQuote(q) {
id: q.id,
quoteNumber: q.quote_number,
customerAccountId: q.customer_account_id,
+ // Migration 121 — Project Overview link (undefined on pre-121 DBs).
+ projectId: q.project_id ?? null,
customer: {
email: q.customer_email,
displayName: q.customer_display_name,
@@ -223,6 +225,8 @@ function mapPayloadToService(body) {
introText: 'introText', outroText: 'outroText',
internalNotes: 'internalNotes', ccPdfEmail: 'ccPdfEmail',
businessBankAccountId: 'businessBankAccountId',
+ // Migration 121 — optional Project Overview link.
+ projectId: 'projectId',
};
for (const [api, svc] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api];
diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js
index 46cc448d..0d0dbd5c 100644
--- a/backend/src/services/contractService.js
+++ b/backend/src/services/contractService.js
@@ -803,6 +803,10 @@ async function createContract(payload, adminId) {
row.event_time_start = payload.eventTimeStart || null;
row.event_time_end = payload.eventTimeEnd || null;
}
+ // Migration 121 — optional link to a Project Overview project.
+ if (payload.projectId !== undefined && await hasColumnCached('contracts', 'project_id')) {
+ row.project_id = payload.projectId || null;
+ }
const inserted = await trx('contracts').insert(row).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -890,6 +894,10 @@ async function updateContract(id, payload, adminId) {
for (const [api, col] of Object.entries(map)) {
if (api in payload) updates[col] = payload[api] || null;
}
+ // Migration 121 — optional Project Overview link.
+ if ('projectId' in payload && await hasColumnCached('contracts', 'project_id')) {
+ updates.project_id = payload.projectId || null;
+ }
await trx('contracts').where({ id }).update(updates);
// Replace inclusions only when the caller sent an explicit list.
diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js
index 9755e22b..eca06f49 100644
--- a/backend/src/services/projectService.js
+++ b/backend/src/services/projectService.js
@@ -15,6 +15,7 @@
const { db } = require('../database/db');
const { AppError } = require('../utils/errors');
+const { hasColumnCached } = require('../utils/schemaCache');
function transformProject(p) {
if (!p) return null;
@@ -94,6 +95,24 @@ async function assignEvent(projectId, eventId) {
return { projectId, eventId };
}
+/** Attach (or, with projectId=null, detach) a quote/contract to a project. */
+async function assignDocument(table, projectId, documentId) {
+ if (!(await hasColumnCached(table, 'project_id'))) {
+ throw new AppError('This instance has no project_id column yet — run migrations', 409);
+ }
+ if (projectId != null) {
+ const project = await db('projects').where({ id: projectId }).first();
+ if (!project) throw new AppError('Project not found', 404);
+ }
+ const doc = await db(table).where({ id: documentId }).first();
+ if (!doc) throw new AppError('Document not found', 404);
+ await db(table).where({ id: documentId }).update({ project_id: projectId || null });
+ return { projectId: projectId || null, documentId };
+}
+
+const assignQuote = (projectId, quoteId) => assignDocument('quotes', projectId, quoteId);
+const assignContract = (projectId, contractId) => assignDocument('contracts', projectId, contractId);
+
/**
* Full overview aggregation for the cockpit. Returns the project, its events,
* and the rolled-up emails / quotes / contracts / invoices / hours + a
@@ -133,20 +152,28 @@ async function getProjectOverview(id, perms = {}) {
.orderBy('issue_date', 'desc');
}
- // Quotes / contracts (by customer — no event_id on those tables).
- if (project.customerAccountId) {
- if (perms.quotes !== false) {
- out.quotes = await db('quotes')
- .where({ customer_account_id: project.customerAccountId })
- .select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid')
- .orderBy('issue_date', 'desc');
- }
- if (perms.contracts !== false) {
- out.contracts = await db('contracts')
- .where({ customer_account_id: project.customerAccountId })
- .select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid')
- .orderBy('issue_date', 'desc');
- }
+ // Quotes / contracts. These tables carry no event_id (migration 107), so
+ // they're linked to the project explicitly via project_id (migration 121).
+ // Where that column doesn't exist yet (pre-121 DB) we fall back to the
+ // project's customer — the original, less precise scoping.
+ const quotesHaveProjectId = await hasColumnCached('quotes', 'project_id');
+ const contractsHaveProjectId = await hasColumnCached('contracts', 'project_id');
+
+ if (perms.quotes !== false && (quotesHaveProjectId || project.customerAccountId)) {
+ let q = db('quotes')
+ .select('id', 'quote_number', 'status', 'issue_date', 'valid_until', 'total_amount_minor', 'currency', 'deal_uuid')
+ .orderBy('issue_date', 'desc');
+ if (quotesHaveProjectId) q = q.where({ project_id: id });
+ else q = q.where({ customer_account_id: project.customerAccountId });
+ out.quotes = await q;
+ }
+ if (perms.contracts !== false && (contractsHaveProjectId || project.customerAccountId)) {
+ let q = db('contracts')
+ .select('id', 'contract_number', 'status', 'issue_date', 'signed_by_customer_at', 'deal_uuid')
+ .orderBy('issue_date', 'desc');
+ if (contractsHaveProjectId) q = q.where({ project_id: id });
+ else q = q.where({ customer_account_id: project.customerAccountId });
+ out.contracts = await q;
}
// Hours (by project_id) — individual entries + total.
@@ -244,6 +271,8 @@ module.exports = {
createProject,
updateProject,
assignEvent,
+ assignQuote,
+ assignContract,
getProjectOverview,
getEmailPreview,
resendEmail,
diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js
index c6304309..81db3d10 100644
--- a/backend/src/services/quoteService.js
+++ b/backend/src/services/quoteService.js
@@ -554,6 +554,10 @@ async function createQuote(payload, adminId) {
created_at: new Date(),
updated_at: new Date(),
};
+ // Migration 121 — optional link to a Project Overview project.
+ if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) {
+ row.project_id = payload.projectId || null;
+ }
const inserted = await trx('quotes').insert(row).returning('id');
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -667,6 +671,10 @@ async function updateQuote(id, payload, adminId) {
? JSON.stringify(payload.installments)
: null;
}
+ // Migration 121 — optional Project Overview link.
+ if (Object.prototype.hasOwnProperty.call(payload, 'projectId') && await hasColumnCached('quotes', 'project_id')) {
+ updates.project_id = payload.projectId || null;
+ }
await trx('quotes').where({ id }).update(updates);
// Delete + reinsert keeps the editor flow simple: the frontend
From 0175007abc675ecb18a30e05241cac62bee833f8 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 13:20:59 +0200
Subject: [PATCH 09/29] feat(projects): gated project pickers on
quote/contract/hours editors
- ProjectSelect: a reusable picker that renders nothing when the projects
flag is off (satisfies 'book to project hidden unless projects enabled').
- projects.service.ts: full frontend API client (list/get/create/update,
overview, assign event/quote/contract, email preview + 4 actions).
- Quote + contract editors carry an optional projectId (state, prefill,
payload); service payload/detail types updated.
- HoursSection gains a 'book to project' control; backend createEntry
persists project_id (migration 118, hasColumnCached guarded).
---
backend/src/routes/adminCustomers.js | 1 +
backend/src/services/customerHoursService.js | 4 +
.../src/components/admin/HoursSection.tsx | 12 ++
.../src/components/admin/ProjectSelect.tsx | 74 +++++++
.../admin/contracts/ContractEditorPage.tsx | 15 ++
.../pages/admin/quotes/QuoteEditorPage.tsx | 16 ++
frontend/src/services/contracts.service.ts | 6 +
.../src/services/customerAdmin.service.ts | 2 +
frontend/src/services/projects.service.ts | 180 ++++++++++++++++++
frontend/src/services/quotes.service.ts | 5 +
10 files changed, 315 insertions(+)
create mode 100644 frontend/src/components/admin/ProjectSelect.tsx
create mode 100644 frontend/src/services/projects.service.ts
diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js
index cc03115c..95c43ec5 100644
--- a/backend/src/routes/adminCustomers.js
+++ b/backend/src/routes/adminCustomers.js
@@ -572,6 +572,7 @@ router.post('/:id/hour-entries', [
body('endTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
+ body('projectId').optional({ nullable: true }).isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerHoursService.createEntry(
diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js
index 2ce2e262..ab24efd6 100644
--- a/backend/src/services/customerHoursService.js
+++ b/backend/src/services/customerHoursService.js
@@ -233,6 +233,10 @@ async function createEntry(customerId, payload, adminId) {
created_at: new Date(),
updated_at: new Date(),
};
+ // Migration 118 — optional "book to project" link.
+ if (payload.projectId !== undefined && await hasColumnCached('customer_hour_entries', 'project_id')) {
+ row.project_id = payload.projectId || null;
+ }
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx
index 6f4abd41..279a644f 100644
--- a/frontend/src/components/admin/HoursSection.tsx
+++ b/frontend/src/components/admin/HoursSection.tsx
@@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
+import { ProjectSelect } from './ProjectSelect';
export interface HoursSectionProps {
customerId: number;
@@ -54,6 +55,8 @@ export const HoursSection: React.FC = ({
const [duration, setDuration] = useState('');
const [rateOverride, setRateOverride] = useState('');
const [description, setDescription] = useState('');
+ // Migration 118 — optional "book to project" link (gated component).
+ const [projectId, setProjectId] = useState(null);
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
// the end-time jumps to start + duration. Pure convenience; the End
@@ -114,6 +117,7 @@ export const HoursSection: React.FC = ({
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
})(),
description: description || null,
+ projectId: projectId ?? null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
@@ -123,6 +127,7 @@ export const HoursSection: React.FC = ({
setDuration('');
setRateOverride('');
setDescription('');
+ setProjectId(null);
toast.success(t('customers.hours.toast.created', 'Entry logged'));
},
onError: (err: any) => {
@@ -348,6 +353,13 @@ export const HoursSection: React.FC = ({
placeholder={t('customers.hours.form.notePlaceholder',
'What was worked on?') as string} />
+ {/* Book to project — renders only when the projects feature is on. */}
+
{noRateConfigured && !overrideTyped && (
diff --git a/frontend/src/components/admin/ProjectSelect.tsx b/frontend/src/components/admin/ProjectSelect.tsx
new file mode 100644
index 00000000..84703076
--- /dev/null
+++ b/frontend/src/components/admin/ProjectSelect.tsx
@@ -0,0 +1,74 @@
+/**
+ * ProjectSelect — a gated project picker reused by the quote / contract /
+ * hours / event editors to link a document to a Project Overview project.
+ *
+ * Renders nothing when the `projects` feature flag is off, so every call
+ * site stays a one-liner that simply vanishes when the feature is disabled
+ * (the maintainer's "book to project must not show unless projects is
+ * enabled" requirement). Customers never see this — admin surfaces only.
+ */
+import React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
+import { projectsService } from '../../services/projects.service';
+
+interface ProjectSelectProps {
+ value: number | null;
+ onChange: (projectId: number | null) => void;
+ /** Optional label above the select. When omitted the select renders bare. */
+ label?: string;
+ /** Restrict the list to a single customer's projects when set. */
+ customerAccountId?: number | null;
+ disabled?: boolean;
+ className?: string;
+}
+
+export const ProjectSelect: React.FC = ({
+ value,
+ onChange,
+ label,
+ customerAccountId,
+ disabled,
+ className,
+}) => {
+ const { t } = useTranslation();
+ const { flags } = useFeatureFlags();
+
+ const { data: projects, isLoading } = useQuery({
+ queryKey: ['projects', 'select'],
+ queryFn: () => projectsService.list(),
+ enabled: !!flags.projects,
+ staleTime: 60_000,
+ });
+
+ // Hard gate: hidden entirely when the feature is off.
+ if (!flags.projects) return null;
+
+ const options = (projects || []).filter(
+ (p) => customerAccountId == null || p.customerAccountId == null || p.customerAccountId === customerAccountId,
+ );
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+ onChange(e.target.value ? Number(e.target.value) : null)}
+ className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-60"
+ >
+ {t('projects.picker.none', 'No project')}
+ {options.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx
index c06ed00a..b11ec9f0 100644
--- a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx
+++ b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx
@@ -24,6 +24,7 @@ import {
CONTRACT_SECTIONS,
} from '../../../services/contracts.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
+import { ProjectSelect } from '../../../components/admin/ProjectSelect';
interface BlockRow {
blockId: number;
@@ -63,6 +64,7 @@ export const ContractEditorPage: React.FC = () => {
const [language, setLanguage] = useState('de');
const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10));
const [validUntil, setValidUntil] = useState('');
+ const [projectId, setProjectId] = useState(null);
const [blocks, setBlocks] = useState([]);
// Load existing contract on edit.
@@ -110,6 +112,7 @@ export const ContractEditorPage: React.FC = () => {
setLanguage(c.language || 'de');
setIssueDate(c.issueDate);
setValidUntil(c.validUntil || '');
+ setProjectId(c.projectId ?? null);
setBlocks((c.inclusions || []).map((inc) => ({
blockId: inc.blockId,
section: inc.section,
@@ -188,6 +191,7 @@ export const ContractEditorPage: React.FC = () => {
outroText: outroText || null,
issueDate,
validUntil: validUntil || undefined,
+ projectId: projectId ?? null,
});
// Apply block toggles + ordering as an update right after create.
await contractsService.update(created.contract.id, {
@@ -220,6 +224,7 @@ export const ContractEditorPage: React.FC = () => {
language,
issueDate,
validUntil: validUntil || undefined,
+ projectId: projectId ?? null,
blocks: blocks.map((b) => ({
blockId: b.blockId, included: b.included, position: b.position,
})),
@@ -348,6 +353,16 @@ export const ContractEditorPage: React.FC = () => {
)}
+ {/* Project link (renders only when the projects feature is on). */}
+
+
diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
index ea044fcb..c8a1dd73 100644
--- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
+++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
@@ -24,6 +24,7 @@ import {
} from '../../../services/quotes.service';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
+import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
@@ -59,6 +60,8 @@ interface FormState {
internalNotes: string;
ccPdfEmail: string;
businessBankAccountId: number | null;
+ /** Migration 121 — optional Project Overview link. */
+ projectId: number | null;
lineItems: EditableLineItem[];
// Ad-hoc installments (commit #6). null = use the payment-timing
// template's installments; array = explicit per-quote override.
@@ -88,6 +91,7 @@ const empty: FormState = {
internalNotes: '',
ccPdfEmail: '',
businessBankAccountId: null,
+ projectId: null,
lineItems: [],
installments: null,
};
@@ -123,6 +127,8 @@ function buildPayload(f: FormState): QuoteCreatePayload {
internalNotes: f.internalNotes || undefined,
ccPdfEmail: f.ccPdfEmail || undefined,
businessBankAccountId: f.businessBankAccountId || undefined,
+ // Migration 121 — Project Overview link. Send null to clear.
+ projectId: f.projectId ?? null,
lineItems: f.lineItems.map((li) => ({
position: li.position,
quantity: li.quantity,
@@ -214,6 +220,7 @@ export const QuoteEditorPage: React.FC = () => {
internalNotes: q.internalNotes || '',
ccPdfEmail: q.ccPdfEmail || '',
businessBankAccountId: q.businessBankAccountId,
+ projectId: q.projectId ?? null,
lineItems: existing.lineItems.map((li) => ({
id: li.id,
position: li.position,
@@ -479,6 +486,15 @@ export const QuoteEditorPage: React.FC = () => {
}))}
searchPlaceholder={t('quotes.customerSearch', 'Search customer by email or company…') as string}
/>
+ {/* Project link (renders only when the projects feature is on). */}
+
+
setForm((f) => ({ ...f, projectId }))}
+ />
+
{/* Section: Event */}
diff --git a/frontend/src/services/contracts.service.ts b/frontend/src/services/contracts.service.ts
index 974e28e9..f2304068 100644
--- a/frontend/src/services/contracts.service.ts
+++ b/frontend/src/services/contracts.service.ts
@@ -109,6 +109,8 @@ export interface ContractSummary {
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
+ /** Migration 121 — Project Overview link (null when unlinked). */
+ projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -194,6 +196,8 @@ export interface ContractCreatePayload {
outroText?: string | null;
issueDate?: string;
validUntil?: string;
+ /** Migration 121 — optional link to a Project Overview project. */
+ projectId?: number | null;
}
export interface ContractUpdatePayload {
@@ -211,6 +215,8 @@ export interface ContractUpdatePayload {
* rows from this payload — caller controls inclusion + per-section
* order via the position field. Omit to leave inclusions untouched. */
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
+ /** Migration 121 — optional Project Overview link. null clears it. */
+ projectId?: number | null;
}
export interface ContractBlockCreatePayload {
diff --git a/frontend/src/services/customerAdmin.service.ts b/frontend/src/services/customerAdmin.service.ts
index dd38f9c9..28028590 100644
--- a/frontend/src/services/customerAdmin.service.ts
+++ b/frontend/src/services/customerAdmin.service.ts
@@ -451,6 +451,8 @@ export interface HourEntryCreatePayload {
endTime: string; // HH:MM
hourlyRateMinorOverride?: number | null;
description?: string | null;
+ /** Migration 118 — optional "book to project" link. */
+ projectId?: number | null;
}
export interface HourEntryUpdatePayload {
diff --git a/frontend/src/services/projects.service.ts b/frontend/src/services/projects.service.ts
new file mode 100644
index 00000000..6ac64b7d
--- /dev/null
+++ b/frontend/src/services/projects.service.ts
@@ -0,0 +1,180 @@
+/**
+ * Admin → Projects API client. Hits /api/admin/projects/*.
+ *
+ * Projects are the admin-only grouping layer above events (Model A). The
+ * cockpit "overview" rolls up the per-event/per-customer documents. Mirrors
+ * the contracts/bills service shape: `data.data || data` unwrap.
+ */
+import { api } from '../config/api';
+
+export type ProjectStatus = 'active' | 'archived' | string;
+
+export interface ProjectSummary {
+ id: number;
+ name: string;
+ customerAccountId: number | null;
+ customerEmail: string | null;
+ status: ProjectStatus;
+ eventCount?: number;
+ createdAt: string | null;
+ updatedAt: string | null;
+}
+
+export interface ProjectEvent {
+ id: number;
+ event_name: string;
+ event_date: string | null;
+ slug: string;
+ is_active: boolean | number;
+ is_draft: boolean | number;
+ expires_at: string | null;
+ is_archived: boolean | number;
+}
+
+export interface ProjectEmail {
+ id: number;
+ recipient: string;
+ type: string;
+ status: string;
+ queuedAt: string | null;
+ sentAt: string | null;
+ error: string | null;
+ eventId: number | null;
+}
+
+export interface ProjectInvoice {
+ id: number;
+ invoice_number: string;
+ status: string;
+ kind: string | null;
+ issue_date: string | null;
+ due_date: string | null;
+ total_amount_minor: number;
+ paid_amount_minor: number | null;
+ paid_at: string | null;
+ currency: string;
+ event_id: number | null;
+ deal_uuid: string | null;
+}
+
+export interface ProjectQuote {
+ id: number;
+ quote_number: string;
+ status: string;
+ issue_date: string | null;
+ valid_until: string | null;
+ total_amount_minor: number;
+ currency: string;
+ deal_uuid: string | null;
+}
+
+export interface ProjectContract {
+ id: number;
+ contract_number: string;
+ status: string;
+ issue_date: string | null;
+ signed_by_customer_at: string | null;
+ deal_uuid: string | null;
+}
+
+export interface ProjectHourEntry {
+ id: number;
+ entry_date: string | null;
+ duration_minutes: number;
+ description: string | null;
+ status: string | null;
+ invoice_id: number | null;
+}
+
+export interface ProjectMilestone {
+ kind: 'quote' | 'contract' | 'gallery' | 'invoice';
+ label: string;
+ date: string | null;
+}
+
+export interface ProjectOverview {
+ project: ProjectSummary;
+ events: ProjectEvent[];
+ emails: ProjectEmail[];
+ quotes: ProjectQuote[];
+ contracts: ProjectContract[];
+ invoices: ProjectInvoice[];
+ hours: { entries: ProjectHourEntry[]; totalMinutes: number };
+ milestones: ProjectMilestone[];
+}
+
+export interface EmailPreview {
+ id: number;
+ recipient: string;
+ type: string;
+ status: string;
+ available: boolean;
+ html: string | null;
+}
+
+export const projectsService = {
+ async list(params: { q?: string; status?: string } = {}): Promise {
+ const { data } = await api.get('/admin/projects', { params });
+ const body = data.data || data;
+ return body.projects || [];
+ },
+
+ async get(id: number): Promise {
+ const { data } = await api.get(`/admin/projects/${id}`);
+ const body = data.data || data;
+ return body.project;
+ },
+
+ async create(payload: { name: string; customerAccountId?: number | null }): Promise {
+ const { data } = await api.post('/admin/projects', payload);
+ const body = data.data || data;
+ return body.project;
+ },
+
+ async update(
+ id: number,
+ payload: { name?: string; customerAccountId?: number | null; status?: string },
+ ): Promise {
+ const { data } = await api.put(`/admin/projects/${id}`, payload);
+ const body = data.data || data;
+ return body.project;
+ },
+
+ async overview(id: number): Promise {
+ const { data } = await api.get(`/admin/projects/${id}/overview`);
+ return (data.data || data) as ProjectOverview;
+ },
+
+ async assignEvent(projectId: number, eventId: number): Promise {
+ await api.post(`/admin/projects/${projectId}/events`, { eventId });
+ },
+
+ async assignQuote(projectId: number, quoteId: number): Promise {
+ await api.post(`/admin/projects/${projectId}/quotes`, { quoteId });
+ },
+
+ async assignContract(projectId: number, contractId: number): Promise {
+ await api.post(`/admin/projects/${projectId}/contracts`, { contractId });
+ },
+
+ async emailPreview(emailId: number): Promise {
+ const { data } = await api.get(`/admin/projects/email/${emailId}/preview`);
+ return (data.data || data) as EmailPreview;
+ },
+
+ async resendEmail(emailId: number): Promise {
+ await api.post(`/admin/projects/email/${emailId}/resend`);
+ },
+
+ async cancelEmail(emailId: number): Promise {
+ await api.post(`/admin/projects/email/${emailId}/cancel`);
+ },
+
+ async retryEmail(emailId: number): Promise {
+ await api.post(`/admin/projects/email/${emailId}/retry`);
+ },
+
+ async sendEmailNow(emailId: number): Promise {
+ await api.post(`/admin/projects/email/${emailId}/send-now`);
+ },
+};
diff --git a/frontend/src/services/quotes.service.ts b/frontend/src/services/quotes.service.ts
index 9c136424..963a2596 100644
--- a/frontend/src/services/quotes.service.ts
+++ b/frontend/src/services/quotes.service.ts
@@ -43,6 +43,8 @@ export interface QuoteSummary {
* doc — contract, invoices, Storni — that shares this deal. */
dealUuid: string | null;
customerAccountId: number;
+ /** Migration 121 — Project Overview link (null when unlinked). */
+ projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -195,6 +197,9 @@ export interface QuoteCreatePayload {
internalNotes?: string;
ccPdfEmail?: string;
businessBankAccountId?: number;
+ /** Migration 121 — optional link to a Project Overview project.
+ * null clears the link; undefined leaves it unchanged. */
+ projectId?: number | null;
lineItems: QuoteLineItem[];
}
From 81553aa0e3476e7fdd2d6fcda7ebfa1f1d7c9b57 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 13:25:38 +0200
Subject: [PATCH 10/29] feat(projects): Project Overview cockpit UI + CRM nav
entry
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ProjectsListPage: searchable list + inline create, under CRM → Overview.
- ProjectCockpitPage: editable header, milestone timeline, and one dated
feed merging emails (with sent-HTML preview modal + resend/cancel/retry/
send-now actions), quotes, contracts, invoices, galleries and hours.
- Routes /admin/clients/projects(/:id) gated by RequireFeature flag=projects.
- ClientsLayout 'Overview' nav entry (top), gated on flags.projects.
- en + de i18n for the projects namespace + book-to-project label.
---
frontend/src/App.tsx | 8 +
.../src/components/admin/ClientsLayout.tsx | 9 +-
frontend/src/i18n/locales/de.json | 59 ++++
frontend/src/i18n/locales/en.json | 59 ++++
.../admin/projects/ProjectCockpitPage.tsx | 310 ++++++++++++++++++
.../pages/admin/projects/ProjectsListPage.tsx | 141 ++++++++
6 files changed, 585 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
create mode 100644 frontend/src/pages/admin/projects/ProjectsListPage.tsx
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a4d72da6..14e85596 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -43,6 +43,8 @@ import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage })));
import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
import { ContractResponsePage } from './pages/public/ContractResponsePage';
+import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
+import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage';
import { ContractsListPage } from './pages/admin/contracts/ContractsListPage';
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
@@ -202,6 +204,12 @@ function App() {
} />
} />
+ {/* Project Overview (CRM) — admin-only grouping
+ layer above events, gated by `projects`. */}
+ }>
+ } />
+ } />
+
{/* Bills / invoices (CRM) — gated by `bills`. */}
}>
} />
diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx
index d3de2e1a..811c350d 100644
--- a/frontend/src/components/admin/ClientsLayout.tsx
+++ b/frontend/src/components/admin/ClientsLayout.tsx
@@ -14,7 +14,7 @@
import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar } from 'lucide-react';
+import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -39,6 +39,13 @@ export const ClientsLayout: React.FC = () => {
const { flags } = useFeatureFlags();
const navItems: NavItem[] = [
+ {
+ key: 'overview',
+ to: '/admin/clients/projects',
+ label: t('clients.subnav.overview', 'Overview'),
+ icon: FolderKanban,
+ featureFlag: 'projects',
+ },
{
key: 'accounts',
to: '/admin/clients/accounts',
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index c955df08..6b41eb92 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3123,6 +3123,7 @@
"rateOverride": "Satz-Override",
"note": "Notiz / Beschreibung",
"notePlaceholder": "Was wurde gearbeitet?",
+ "bookToProject": "Auf Projekt buchen",
"save": "Eintrag hinzufügen",
"needRate": "Satz festlegen oder Override eingeben, um Zeit zu erfassen."
},
@@ -3370,6 +3371,7 @@
"body": "Aktiviere „Konten\" (oder eine andere CRM-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen."
},
"subnav": {
+ "overview": "Übersicht",
"accounts": "Konten",
"quotes": "Angebote",
"contracts": "Verträge",
@@ -3380,6 +3382,63 @@
"development": "Entwicklung"
}
},
+ "projects": {
+ "title": "Projektübersicht",
+ "subtitle": "Fasse Events zu Projekten zusammen und sieh jede E-Mail, jedes Dokument, jede Galerie und jede Stunde in einem Cockpit.",
+ "search": "Nach Name oder Kunde suchen…",
+ "empty": "Noch keine Projekte. Erstelle oben eines, oder vorhandene Events wurden automatisch gruppiert.",
+ "notFound": "Projekt nicht gefunden",
+ "backToList": "Alle Projekte",
+ "noCustomer": "Kein einzelner Kunde",
+ "rename": "Umbenennen",
+ "timeline": "Meilensteine",
+ "eventCount": "{{count}} Events",
+ "totalHours": "{{hours}} erfasst",
+ "create": {
+ "label": "Name des neuen Projekts",
+ "placeholder": "z. B. Hochzeit Müller 2026",
+ "button": "Projekt erstellen"
+ },
+ "col": {
+ "name": "Projekt",
+ "customer": "Kunde",
+ "events": "Events",
+ "status": "Status",
+ "updated": "Aktualisiert"
+ },
+ "picker": {
+ "label": "Projekt",
+ "none": "Kein Projekt"
+ },
+ "feed": {
+ "title": "Aktivität",
+ "empty": "Diesem Projekt ist noch nichts zugeordnet.",
+ "email": "E-Mail",
+ "quote": "Angebot",
+ "contract": "Vertrag",
+ "invoice": "Rechnung",
+ "gallery": "Galerie",
+ "hours": "Stunden"
+ },
+ "email": {
+ "preview": "Vorschau",
+ "resend": "Erneut senden",
+ "sendNow": "Jetzt senden",
+ "cancel": "Abbrechen",
+ "retry": "Wiederholen",
+ "previewTitle": "E-Mail-Vorschau",
+ "noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden."
+ },
+ "toast": {
+ "created": "Projekt erstellt",
+ "createFailed": "Projekt konnte nicht erstellt werden",
+ "saved": "Projekt gespeichert",
+ "saveFailed": "Speichern fehlgeschlagen",
+ "emailAction": "Erledigt",
+ "emailActionFailed": "Aktion fehlgeschlagen",
+ "previewFailed": "Vorschau konnte nicht geladen werden"
+ }
+ },
"calendar": {
"pageTitle": "Kalender",
"subtitle": "Termine, erfasste Stunden und offene Angebote/Verträge in einer Ansicht.",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 7e8c5a03..dccf833f 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3123,6 +3123,7 @@
"rateOverride": "Rate override",
"note": "Note / description",
"notePlaceholder": "What was worked on?",
+ "bookToProject": "Book to project",
"save": "Add entry",
"needRate": "Set a rate or enter an override to log time."
},
@@ -3370,6 +3371,7 @@
"body": "Enable Accounts (or another CRM sub-feature) under Settings → Features to get started."
},
"subnav": {
+ "overview": "Overview",
"accounts": "Accounts",
"quotes": "Quotes",
"contracts": "Contracts",
@@ -3380,6 +3382,63 @@
"development": "Development"
}
},
+ "projects": {
+ "title": "Project Overview",
+ "subtitle": "Group events into projects and see every email, document, gallery and hour in one cockpit.",
+ "search": "Search by name or customer…",
+ "empty": "No projects yet. Create one above, or events you already have were grouped automatically.",
+ "notFound": "Project not found",
+ "backToList": "All projects",
+ "noCustomer": "No single customer",
+ "rename": "Rename",
+ "timeline": "Milestones",
+ "eventCount": "{{count}} events",
+ "totalHours": "{{hours}} logged",
+ "create": {
+ "label": "New project name",
+ "placeholder": "e.g. Müller wedding 2026",
+ "button": "Create project"
+ },
+ "col": {
+ "name": "Project",
+ "customer": "Customer",
+ "events": "Events",
+ "status": "Status",
+ "updated": "Updated"
+ },
+ "picker": {
+ "label": "Project",
+ "none": "No project"
+ },
+ "feed": {
+ "title": "Activity",
+ "empty": "Nothing rolled up to this project yet.",
+ "email": "Email",
+ "quote": "Quote",
+ "contract": "Contract",
+ "invoice": "Invoice",
+ "gallery": "Gallery",
+ "hours": "Hours"
+ },
+ "email": {
+ "preview": "Preview",
+ "resend": "Resend",
+ "sendNow": "Send now",
+ "cancel": "Cancel",
+ "retry": "Retry",
+ "previewTitle": "Email preview",
+ "noPreview": "No stored preview for this email — it was sent before previews were captured."
+ },
+ "toast": {
+ "created": "Project created",
+ "createFailed": "Could not create project",
+ "saved": "Project saved",
+ "saveFailed": "Save failed",
+ "emailAction": "Done",
+ "emailActionFailed": "Action failed",
+ "previewFailed": "Could not load preview"
+ }
+ },
"calendar": {
"pageTitle": "Calendar",
"subtitle": "Events, logged hours, and pending quotes/contracts in one view.",
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
new file mode 100644
index 00000000..3efcbf03
--- /dev/null
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -0,0 +1,310 @@
+/**
+ * Admin → Project Cockpit (the 360° Project Overview).
+ *
+ * One project, everything in it: an editable header, a milestone timeline,
+ * and a single dated feed merging every email (with the actual sent HTML
+ * preview + resend/cancel/retry/send-now actions), quote, contract, invoice,
+ * 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 { useTranslation } from 'react-i18next';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'react-toastify';
+import {
+ Mail, FileText, ScrollText, Receipt, Image as ImageIcon, Clock,
+ X, Send, RotateCw, Ban, Eye, Save, ArrowLeft,
+} from 'lucide-react';
+import { Button, Card, Input, Loading } from '../../../components/common';
+import {
+ projectsService,
+ type ProjectOverview,
+ type EmailPreview,
+} from '../../../services/projects.service';
+import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
+import { formatMoneyMinor } from '../../../utils/money';
+
+type FeedKind = 'email' | 'quote' | 'contract' | 'invoice' | 'gallery' | 'hours';
+
+interface FeedItem {
+ key: string;
+ kind: FeedKind;
+ date: string | null;
+ title: string;
+ subtitle?: string;
+ amount?: string;
+ status?: string;
+ emailId?: number;
+ emailStatus?: string;
+}
+
+const KIND_ICON: Record> = {
+ email: Mail,
+ quote: FileText,
+ contract: ScrollText,
+ invoice: Receipt,
+ gallery: ImageIcon,
+ hours: Clock,
+};
+
+function minutesToHours(min: number): string {
+ const h = Math.floor(min / 60);
+ const m = min % 60;
+ return m === 0 ? `${h}h` : `${h}h ${m}m`;
+}
+
+export const ProjectCockpitPage: React.FC = () => {
+ const { t } = useTranslation();
+ const { id } = useParams<{ id: string }>();
+ const projectId = id ? parseInt(id, 10) : null;
+ const qc = useQueryClient();
+ const { format, formatTime } = useLocalizedDate();
+
+ const [editName, setEditName] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [previewLoading, setPreviewLoading] = useState(false);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['project-overview', projectId],
+ queryFn: () => projectsService.overview(projectId as number),
+ enabled: projectId !== null,
+ });
+
+ const renameMutation = useMutation({
+ mutationFn: (name: string) => projectsService.update(projectId as number, { name }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
+ qc.invalidateQueries({ queryKey: ['projects'] });
+ setEditName(null);
+ toast.success(t('projects.toast.saved', 'Project saved') as string);
+ },
+ onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.saveFailed', 'Save failed') as string)),
+ });
+
+ const emailActionMutation = useMutation({
+ mutationFn: ({ action, emailId }: { action: 'resend' | 'cancel' | 'retry' | 'sendNow'; emailId: number }) => {
+ if (action === 'resend') return projectsService.resendEmail(emailId);
+ if (action === 'cancel') return projectsService.cancelEmail(emailId);
+ if (action === 'retry') return projectsService.retryEmail(emailId);
+ return projectsService.sendEmailNow(emailId);
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
+ toast.success(t('projects.toast.emailAction', 'Done') as string);
+ },
+ onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.emailActionFailed', 'Action failed') as string)),
+ });
+
+ const openPreview = async (emailId: number) => {
+ setPreviewLoading(true);
+ try {
+ const p = await projectsService.emailPreview(emailId);
+ setPreview(p);
+ } catch (err: any) {
+ toast.error(err?.response?.data?.error || (t('projects.toast.previewFailed', 'Could not load preview') as string));
+ } finally {
+ setPreviewLoading(false);
+ }
+ };
+
+ // Merge every rolled-up document into one dated feed (newest first).
+ const feed = useMemo(() => {
+ if (!data) return [];
+ const items: FeedItem[] = [];
+ for (const e of data.emails) {
+ items.push({
+ key: `email-${e.id}`, kind: 'email', date: e.sentAt || e.queuedAt,
+ title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`,
+ subtitle: e.recipient + (e.error ? ` — ${e.error}` : ''),
+ status: e.status, emailId: e.id, emailStatus: e.status,
+ });
+ }
+ for (const q of data.quotes) {
+ 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),
+ });
+ }
+ 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,
+ });
+ }
+ 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),
+ });
+ }
+ 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,
+ });
+ }
+ for (const h of data.hours.entries) {
+ items.push({
+ key: `hours-${h.id}`, kind: 'hours', date: h.entry_date,
+ title: t('projects.feed.hours', 'Hours') + ` · ${minutesToHours(h.duration_minutes)}`,
+ subtitle: h.description || undefined, status: h.status || undefined,
+ });
+ }
+ return items.sort((a, b) => {
+ const da = a.date ? new Date(a.date).getTime() : 0;
+ const db = b.date ? new Date(b.date).getTime() : 0;
+ return db - da;
+ });
+ }, [data, t]);
+
+ if (isLoading) return ;
+ if (!data) return {t('projects.notFound', 'Project not found')}
;
+
+ const { project, milestones, hours } = data;
+
+ return (
+
+
+
{t('projects.backToList', 'All projects')}
+
+
+ {/* Header */}
+
+
+
+ {editName === null ? (
+
{project.name}
+ ) : (
+
+ setEditName(e.target.value)} className="max-w-sm" />
+ renameMutation.mutate(editName.trim())}>
+
+
+ setEditName(null)}>
+
+ )}
+
+ {project.customerEmail || t('projects.noCustomer', 'No single customer')}
+ {' · '}
+ {t('projects.eventCount', '{{count}} events', { count: data.events.length })}
+ {' · '}
+ {t('projects.totalHours', '{{hours}} logged', { hours: minutesToHours(hours.totalMinutes) })}
+
+
+ {editName === null && (
+
setEditName(project.name)}>{t('projects.rename', 'Rename')}
+ )}
+
+
+
+ {/* Milestone timeline */}
+ {milestones && milestones.length > 0 && (
+
+ {t('projects.timeline', 'Milestones')}
+
+ {milestones.map((m, i) => {
+ const Icon = KIND_ICON[m.kind] || FileText;
+ return (
+
+
+
+
{m.label}
+
{m.date ? format(m.date) : '—'}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Dated feed */}
+
+ {t('projects.feed.title', 'Activity')}
+ {feed.length === 0 ? (
+ {t('projects.feed.empty', 'Nothing rolled up to this project yet.')}
+ ) : (
+
+ {feed.map((item) => {
+ const Icon = KIND_ICON[item.kind];
+ return (
+
+
+
+
+ {item.title}
+
+ {item.date ? `${format(item.date)} ${item.kind === 'email' ? formatTime(item.date) : ''}` : '—'}
+
+
+ {item.subtitle &&
{item.subtitle}
}
+
+ {item.status && (
+
{item.status}
+ )}
+ {item.amount &&
{item.amount} }
+ {item.kind === 'email' && item.emailId != null && (
+
+ openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
+ {t('projects.email.preview', 'Preview')}
+
+ {item.emailStatus === 'sent' && (
+ emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
+ {t('projects.email.resend', 'Resend')}
+
+ )}
+ {item.emailStatus === 'pending' && (
+ <>
+ emailActionMutation.mutate({ action: 'sendNow', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
+ {t('projects.email.sendNow', 'Send now')}
+
+ emailActionMutation.mutate({ action: 'cancel', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-red-600 hover:underline">
+ {t('projects.email.cancel', 'Cancel')}
+
+ >
+ )}
+ {item.emailStatus === 'failed' && (
+ emailActionMutation.mutate({ action: 'retry', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-amber-600 hover:underline">
+ {t('projects.email.retry', 'Retry')}
+
+ )}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Email preview modal */}
+ {(preview || previewLoading) && (
+
setPreview(null)}>
+
e.stopPropagation()}>
+
+
{t('projects.email.previewTitle', 'Email preview')}
+ setPreview(null)} className="text-neutral-500 hover:text-neutral-700">
+
+
+ {previewLoading ? (
+
+ ) : preview && preview.available && preview.html ? (
+
+ ) : (
+
+ {t('projects.email.noPreview', 'No stored preview for this email — it was sent before previews were captured.')}
+
+ )}
+
+
+
+ )}
+
+ );
+};
diff --git a/frontend/src/pages/admin/projects/ProjectsListPage.tsx b/frontend/src/pages/admin/projects/ProjectsListPage.tsx
new file mode 100644
index 00000000..2b827612
--- /dev/null
+++ b/frontend/src/pages/admin/projects/ProjectsListPage.tsx
@@ -0,0 +1,141 @@
+/**
+ * Admin → Project Overview list page.
+ *
+ * Lists every project (the admin-only grouping layer above events) with a
+ * search box, an inline "new project" creator, and a click-through to each
+ * project's cockpit. Visual shape mirrors the other /admin/clients lists so
+ * the CRM area feels like one product. Admin-only — customers never see it.
+ */
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'react-toastify';
+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';
+
+export const ProjectsListPage: React.FC = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const { format } = useLocalizedDate();
+ const [search, setSearch] = useState('');
+ const [newName, setNewName] = useState('');
+
+ const { data: projects, isLoading } = useQuery({
+ queryKey: ['projects', { search }],
+ queryFn: () => projectsService.list({ q: search || undefined }),
+ });
+
+ const createMutation = useMutation({
+ mutationFn: () => projectsService.create({ name: newName.trim() }),
+ onSuccess: (project) => {
+ qc.invalidateQueries({ queryKey: ['projects'] });
+ setNewName('');
+ toast.success(t('projects.toast.created', 'Project created') as string);
+ navigate(`/admin/clients/projects/${project.id}`);
+ },
+ onError: (err: any) => {
+ toast.error(err?.response?.data?.error || err?.message || (t('projects.toast.createFailed', 'Could not create project') as string));
+ },
+ });
+
+ return (
+
+
+
+
+
+ {t('projects.title', 'Project Overview')}
+
+
+ {t('projects.subtitle', 'Group events into projects and see every email, document, gallery and hour in one cockpit.')}
+
+
+
+
+ {/* Inline create */}
+
+
+
+
+ {t('projects.create.label', 'New project name')}
+
+ setNewName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter' && newName.trim()) createMutation.mutate(); }}
+ placeholder={t('projects.create.placeholder', 'e.g. Müller wedding 2026') as string}
+ />
+
+
createMutation.mutate()}
+ >
+ {t('projects.create.button', 'Create project')}
+
+
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ placeholder={t('projects.search', 'Search by name or customer…') as string}
+ className="pl-9"
+ />
+
+
+ {isLoading ? (
+
+ ) : !projects || projects.length === 0 ? (
+
+
+ {t('projects.empty', 'No projects yet. Create one above, or events you already have were grouped automatically.')}
+
+
+ ) : (
+
+
+
+
+
+ {t('projects.col.name', 'Project')}
+ {t('projects.col.customer', 'Customer')}
+ {t('projects.col.events', 'Events')}
+ {t('projects.col.status', 'Status')}
+ {t('projects.col.updated', 'Updated')}
+
+
+
+ {projects.map((p: ProjectSummary) => (
+ navigate(`/admin/clients/projects/${p.id}`)}
+ className="border-t border-neutral-100 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/60 cursor-pointer"
+ >
+ {p.name}
+ {p.customerEmail || '—'}
+ {p.eventCount ?? 0}
+
+
+ {p.status}
+
+
+ {p.updatedAt ? format(p.updatedAt) : '—'}
+
+ ))}
+
+
+
+
+ )}
+
+ );
+};
From dffcf6269fe0416d7233d973a4162874337fbb9a Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 13:27:43 +0200
Subject: [PATCH 11/29] feat(projects): attach-event control in the cockpit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Search any event by name and attach it to the project (re-points
events.project_id via assignEvent). Lists the project's current events
above the search. en + de i18n. Completes event grouping UX — admins
can now regroup the auto-created per-event projects however they like.
---
frontend/src/i18n/locales/de.json | 7 ++
frontend/src/i18n/locales/en.json | 7 ++
.../admin/projects/ProjectCockpitPage.tsx | 66 ++++++++++++++++++-
3 files changed, 79 insertions(+), 1 deletion(-)
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 6b41eb92..8a1a0ed1 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3410,6 +3410,13 @@
"label": "Projekt",
"none": "Kein Projekt"
},
+ "events": {
+ "title": "Events",
+ "none": "Diesem Projekt sind noch keine Events zugeordnet.",
+ "searchPlaceholder": "Event zuordnen — nach Name suchen…",
+ "attached": "Event zugeordnet",
+ "attachFailed": "Event konnte nicht zugeordnet werden"
+ },
"feed": {
"title": "Aktivität",
"empty": "Diesem Projekt ist noch nichts zugeordnet.",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index dccf833f..9baa4d41 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3410,6 +3410,13 @@
"label": "Project",
"none": "No project"
},
+ "events": {
+ "title": "Events",
+ "none": "No events grouped under this project yet.",
+ "searchPlaceholder": "Attach an event — search by name…",
+ "attached": "Event attached",
+ "attachFailed": "Could not attach event"
+ },
"feed": {
"title": "Activity",
"empty": "Nothing rolled up to this project yet.",
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index 3efcbf03..bbfc65c0 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -13,7 +13,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import {
Mail, FileText, ScrollText, Receipt, Image as ImageIcon, Clock,
- X, Send, RotateCw, Ban, Eye, Save, ArrowLeft,
+ X, Send, RotateCw, Ban, Eye, Save, ArrowLeft, Plus, Search,
} from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import {
@@ -21,6 +21,7 @@ import {
type ProjectOverview,
type EmailPreview,
} from '../../../services/projects.service';
+import { eventsService } from '../../../services/events.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { formatMoneyMinor } from '../../../utils/money';
@@ -63,6 +64,7 @@ export const ProjectCockpitPage: React.FC = () => {
const [editName, setEditName] = useState(null);
const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
+ const [eventSearch, setEventSearch] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['project-overview', projectId],
@@ -95,6 +97,25 @@ export const ProjectCockpitPage: React.FC = () => {
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.emailActionFailed', 'Action failed') as string)),
});
+ // Event search for the "attach event" control (results exclude events
+ // already on this project).
+ const { data: eventResults } = useQuery({
+ queryKey: ['project-event-search', eventSearch],
+ queryFn: () => eventsService.getEvents(1, 10, undefined, eventSearch),
+ enabled: eventSearch.trim().length >= 2,
+ });
+
+ const attachEventMutation = useMutation({
+ mutationFn: (eventId: number) => projectsService.assignEvent(projectId as number, eventId),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
+ qc.invalidateQueries({ queryKey: ['projects'] });
+ setEventSearch('');
+ toast.success(t('projects.events.attached', 'Event attached') as string);
+ },
+ onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.events.attachFailed', 'Could not attach event') as string)),
+ });
+
const openPreview = async (emailId: number) => {
setPreviewLoading(true);
try {
@@ -201,6 +222,49 @@ export const ProjectCockpitPage: React.FC = () => {
+ {/* Events in this project + attach control */}
+
+ {t('projects.events.title', 'Events')}
+ {data.events.length === 0 ? (
+ {t('projects.events.none', 'No events grouped under this project yet.')}
+ ) : (
+
+ {data.events.map((ev) => (
+
+ {ev.event_name}
+ {ev.event_date ? format(ev.event_date) : '—'}
+
+ ))}
+
+ )}
+
+
+
setEventSearch(e.target.value)}
+ placeholder={t('projects.events.searchPlaceholder', 'Attach an event — search by name…') as string}
+ className="pl-9"
+ />
+ {eventSearch.trim().length >= 2 && eventResults?.events && eventResults.events.length > 0 && (
+
+ {eventResults.events
+ .filter((ev: any) => !data.events.some((existing) => existing.id === ev.id))
+ .map((ev: any) => (
+
attachEventMutation.mutate(ev.id)}
+ className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700"
+ >
+
+ {ev.event_name}
+ {ev.event_date ? format(ev.event_date) : ''}
+
+ ))}
+
+ )}
+
+
+
{/* Milestone timeline */}
{milestones && milestones.length > 0 && (
From 7ca243780a2165b28583a44632468fd05c4ed115 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 13:48:46 +0200
Subject: [PATCH 12/29] 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.
---
backend/src/routes/adminProjects.js | 7 ++
backend/src/services/projectService.js | 110 +++++++++++++++++-
frontend/src/i18n/locales/de.json | 5 +
frontend/src/i18n/locales/en.json | 5 +
.../admin/projects/ProjectCockpitPage.tsx | 26 ++++-
.../pages/admin/projects/ProjectsListPage.tsx | 11 ++
frontend/src/services/projects.service.ts | 8 ++
7 files changed, 165 insertions(+), 7 deletions(-)
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 && (
- setEditName(project.name)}>{t('projects.rename', 'Rename')}
- )}
+
+ {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 && (
+
setEditName(project.name)}>{t('projects.rename', 'Rename')}
+ )}
+
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 {
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 13/29] 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;
}
From f02fba63325115fe416366c5d1c4f38900386cce Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 22:47:43 +0200
Subject: [PATCH 14/29] fix(projects): scope email rollup to CRM types +
re-render unstored previews
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- The customer address often doubles as the admin notification target, so
matching emails purely by recipient swept in system alerts (backup_failed,
restore_failed, …). The recipient match is now restricted to CRM document
types (quote_/contract_/invoice_/storno_); event-scoped mails still match
by event_id.
- getEmailPreview now falls back to renderQueuedEmail() — re-rendering from
the current template + the row's stored email_data — for emails sent before
rendered_html capture, flagged exact:false with an amber 're-rendered' note.
Only a missing template / no variables falls through to 'nothing stored'.
Known limitation: a customer with multiple projects sees their event_id=null
CRM mails under each (email_queue has no project_id).
---
backend/src/services/emailProcessor.js | 17 ++++++
backend/src/services/projectService.js | 55 +++++++++++++++----
frontend/src/i18n/locales/de.json | 3 +-
frontend/src/i18n/locales/en.json | 3 +-
.../admin/projects/ProjectCockpitPage.tsx | 9 ++-
frontend/src/services/projects.service.ts | 3 +
6 files changed, 76 insertions(+), 14 deletions(-)
diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js
index 2539dcc5..3eabae95 100644
--- a/backend/src/services/emailProcessor.js
+++ b/backend/src/services/emailProcessor.js
@@ -772,6 +772,22 @@ async function sendTemplateEmail(to, templateKey, variables) {
}
}
+/**
+ * Render a queued email's HTML WITHOUT sending it. Used by the Project
+ * Overview cockpit to preview emails that predate the rendered_html column
+ * (so nothing was stored at send time). The result is rendered from the
+ * CURRENT template + the row's stored variables, so it's a faithful
+ * approximation rather than the exact bytes that were sent — callers flag
+ * it as a re-render. Returns null when the template no longer exists.
+ */
+async function renderQueuedEmail(templateKey, variables = {}, to = '') {
+ const template = await db('email_templates').where('template_key', templateKey).first();
+ if (!template) return null;
+ const language = await getRecipientLanguage(to, variables.eventId || null);
+ const { subject, htmlBody } = await processTemplate(template, variables, language);
+ return { subject, html: htmlBody };
+}
+
// Process email queue.
//
// Options:
@@ -1074,6 +1090,7 @@ module.exports = {
initializeTransporter,
startEmailQueueProcessor,
sendTemplateEmail,
+ renderQueuedEmail,
processEmailQueue,
queueEmail,
stopEmailQueueProcessor,
diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js
index 38c260ea..f7cefeb1 100644
--- a/backend/src/services/projectService.js
+++ b/backend/src/services/projectService.js
@@ -230,15 +230,30 @@ async function getProjectOverview(id, perms = {}) {
const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } };
// 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.
+ // lazily by the preview endpoint. Gallery/event mails carry event_id; CRM
+ // document mails (quote_/contract_/invoice_/storno_) are queued with
+ // event_id=null, so we ALSO match the project customer's address — but
+ // ONLY for those CRM document types. Without that type filter, system /
+ // admin alerts (backup_failed, restore_failed, …) sent to the same inbox
+ // (the customer address often doubles as the admin notification target)
+ // would wrongly surface under the project.
const customerEmail = project.customerEmail || null;
if (eventIds.length || customerEmail) {
const emails = await db('email_queue')
.where(function () {
if (eventIds.length) this.whereIn('event_id', eventIds);
- if (customerEmail) this.orWhere('recipient_email', customerEmail);
+ if (customerEmail) {
+ this.orWhere(function () {
+ this.where('recipient_email', customerEmail).andWhere(function () {
+ // LIKE '_' is a single-char wildcard that matches the literal
+ // underscore in every CRM type; no escape clause needed and no
+ // real type collides with the trailing '%'.
+ for (const prefix of ['quote_%', 'contract_%', 'invoice_%', 'storno_%']) {
+ this.orWhere('email_type', 'like', prefix);
+ }
+ });
+ });
+ }
})
.select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id')
.orderBy('created_at', 'desc')
@@ -311,24 +326,42 @@ async function getProjectOverview(id, perms = {}) {
}
/**
- * The ACTUAL sent HTML for an email_queue row (cockpit preview). Rows sent
- * before the rendered_html column existed have none → `available:false`, the
- * frontend then shows a "preview not stored" note rather than a stale
- * re-render.
+ * HTML preview for an email_queue row (cockpit). Prefers the exact bytes
+ * stored at send time (rendered_html). Rows sent before that column existed
+ * have none — we then RE-RENDER from the current template + the row's stored
+ * variables (email_data) so the admin still sees the email, flagged `exact:
+ * false`. Only when even re-rendering fails (template gone / no variables)
+ * does `available:false` fall through to the "nothing stored" note.
*/
async function getEmailPreview(emailId) {
const row = await db('email_queue')
.where({ id: emailId })
- .select('id', 'recipient_email', 'email_type', 'status', 'rendered_html')
+ .select('id', 'recipient_email', 'email_type', 'status', 'rendered_html', 'email_data')
.first();
if (!row) throw new AppError('Email not found', 404);
+
+ if (row.rendered_html) {
+ return { id: row.id, recipient: row.recipient_email, type: row.email_type, status: row.status, available: true, exact: true, html: row.rendered_html };
+ }
+
+ // Fallback: re-render from the current template + stored variables.
+ let html = null;
+ try {
+ let variables = row.email_data;
+ if (typeof variables === 'string') variables = JSON.parse(variables);
+ const { renderQueuedEmail } = require('./emailProcessor');
+ const rendered = await renderQueuedEmail(row.email_type, variables || {}, row.recipient_email);
+ html = rendered && rendered.html ? rendered.html : null;
+ } catch (_) { html = null; }
+
return {
id: row.id,
recipient: row.recipient_email,
type: row.email_type,
status: row.status,
- available: !!row.rendered_html,
- html: row.rendered_html || null,
+ available: !!html,
+ exact: false,
+ html,
};
}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 44123a08..7ca81d4d 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3439,7 +3439,8 @@
"cancel": "Abbrechen",
"retry": "Wiederholen",
"previewTitle": "E-Mail-Vorschau",
- "noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden."
+ "noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden.",
+ "reRendered": "Neu gerendert aus der aktuellen Vorlage — diese E-Mail wurde gesendet, bevor Vorschauen erfasst wurden, und kann daher leicht von der tatsächlich versendeten abweichen."
},
"toast": {
"created": "Projekt erstellt",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index d2e1c44d..cda08043 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3439,7 +3439,8 @@
"cancel": "Cancel",
"retry": "Retry",
"previewTitle": "Email preview",
- "noPreview": "No stored preview for this email — it was sent before previews were captured."
+ "noPreview": "No stored preview for this email — it was sent before previews were captured.",
+ "reRendered": "Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received."
},
"toast": {
"created": "Project created",
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index b24c6d61..a81fbcd6 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -404,7 +404,14 @@ export const ProjectCockpitPage: React.FC = () => {
{previewLoading ? (
) : preview && preview.available && preview.html ? (
-
+ <>
+ {!preview.exact && (
+
+ {t('projects.email.reRendered', 'Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.')}
+
+ )}
+
+ >
) : (
{t('projects.email.noPreview', 'No stored preview for this email — it was sent before previews were captured.')}
diff --git a/frontend/src/services/projects.service.ts b/frontend/src/services/projects.service.ts
index fb604e29..615fc2a7 100644
--- a/frontend/src/services/projects.service.ts
+++ b/frontend/src/services/projects.service.ts
@@ -118,6 +118,9 @@ export interface EmailPreview {
type: string;
status: string;
available: boolean;
+ /** true = exact bytes stored at send time; false = re-rendered from the
+ * current template (approximation for emails sent before capture). */
+ exact: boolean;
html: string | null;
}
From 94f2c01590d52962c565eea5d6f47444ff0d5782 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 22:50:03 +0200
Subject: [PATCH 15/29] feat(projects): flag re-rendered emails in the feed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Each email in the rollup now carries a 'stored' flag (rendered_html present).
Emails without an exact stored copy show an amber '≈ re-rendered' tag next to
Preview, so it's visible at a glance — not just inside the modal. en + de.
---
backend/src/services/projectService.js | 6 +++++-
frontend/src/i18n/locales/de.json | 3 ++-
frontend/src/i18n/locales/en.json | 3 ++-
.../src/pages/admin/projects/ProjectCockpitPage.tsx | 11 ++++++++++-
frontend/src/services/projects.service.ts | 2 ++
5 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js
index f7cefeb1..7b635f4e 100644
--- a/backend/src/services/projectService.js
+++ b/backend/src/services/projectService.js
@@ -255,12 +255,16 @@ async function getProjectOverview(id, perms = {}) {
});
}
})
- .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',
+ // Exact stored preview available? (CASE is cross-DB: SQLite→0/1, PG→int)
+ db.raw('CASE WHEN rendered_html IS NOT NULL THEN 1 ELSE 0 END as has_rendered'))
.orderBy('created_at', 'desc')
.limit(200);
out.emails = emails.map((e) => ({
id: e.id, recipient: e.recipient_email, type: e.email_type, status: e.status,
queuedAt: e.created_at, sentAt: e.sent_at, error: e.error_message, eventId: e.event_id,
+ // false → the cockpit preview will re-render from the current template.
+ stored: !!Number(e.has_rendered),
}));
}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 7ca81d4d..a16ae06a 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3440,7 +3440,8 @@
"retry": "Wiederholen",
"previewTitle": "E-Mail-Vorschau",
"noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden.",
- "reRendered": "Neu gerendert aus der aktuellen Vorlage — diese E-Mail wurde gesendet, bevor Vorschauen erfasst wurden, und kann daher leicht von der tatsächlich versendeten abweichen."
+ "reRendered": "Neu gerendert aus der aktuellen Vorlage — diese E-Mail wurde gesendet, bevor Vorschauen erfasst wurden, und kann daher leicht von der tatsächlich versendeten abweichen.",
+ "reRenderedTag": "≈ neu gerendert"
},
"toast": {
"created": "Projekt erstellt",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index cda08043..1bbca587 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3440,7 +3440,8 @@
"retry": "Retry",
"previewTitle": "Email preview",
"noPreview": "No stored preview for this email — it was sent before previews were captured.",
- "reRendered": "Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received."
+ "reRendered": "Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.",
+ "reRenderedTag": "≈ re-rendered"
},
"toast": {
"created": "Project created",
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index a81fbcd6..593bba12 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -39,6 +39,7 @@ interface FeedItem {
href?: string | null;
emailId?: number;
emailStatus?: string;
+ reRendered?: boolean;
}
/** Detail-page route for a clickable document, or null when there isn't one
@@ -153,7 +154,7 @@ export const ProjectCockpitPage: React.FC = () => {
key: `email-${e.id}`, kind: 'email', date: e.sentAt || e.queuedAt,
title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`,
subtitle: e.recipient + (e.error ? ` — ${e.error}` : ''),
- status: e.status, emailId: e.id, emailStatus: e.status,
+ status: e.status, emailId: e.id, emailStatus: e.status, reRendered: !e.stored,
});
}
for (const q of data.quotes) {
@@ -361,6 +362,14 @@ export const ProjectCockpitPage: React.FC = () => {
openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
{t('projects.email.preview', 'Preview')}
+ {item.reRendered && (
+
+ {t('projects.email.reRenderedTag', '≈ re-rendered')}
+
+ )}
{item.emailStatus === 'sent' && (
emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
{t('projects.email.resend', 'Resend')}
diff --git a/frontend/src/services/projects.service.ts b/frontend/src/services/projects.service.ts
index 615fc2a7..1760d8ea 100644
--- a/frontend/src/services/projects.service.ts
+++ b/frontend/src/services/projects.service.ts
@@ -47,6 +47,8 @@ export interface ProjectEmail {
sentAt: string | null;
error: string | null;
eventId: number | null;
+ /** true = exact HTML stored at send time; false = preview re-rendered. */
+ stored: boolean;
}
export interface ProjectInvoice {
From 236932325971e23db17d6245d1ab2e9f2c1b4eec Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:05:49 +0200
Subject: [PATCH 16/29] fix(projects): only link cockpit rows when the target
feature is enabled
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cockpit surfaces quotes/invoices/contracts by PERMISSION, but their
detail routes are gated by feature FLAG (RequireFeature). With those flags
off, clicking a row navigated to a route that redirects to /admin/dashboard
— so links 'did nothing' while the email action buttons (plain API calls)
worked. hrefFor now returns null when the destination flag is off, so the
row renders as non-clickable text instead of a dead link. Galleries/events
are never flag-gated, so they always link.
---
.../admin/projects/ProjectCockpitPage.tsx | 36 ++++++++++++++-----
1 file changed, 28 insertions(+), 8 deletions(-)
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index 593bba12..e3088830 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -25,6 +25,7 @@ import {
import { eventsService } from '../../../services/events.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { formatMoneyMinor } from '../../../utils/money';
+import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext';
type FeedKind = 'email' | 'quote' | 'contract' | 'invoice' | 'gallery' | 'hours';
@@ -42,10 +43,28 @@ interface FeedItem {
reRendered?: boolean;
}
+/** The feature flag that gates each document's detail ROUTE (RequireFeature
+ * in App.tsx). The cockpit surfaces docs by permission, but their detail
+ * pages live behind these flags — so a link is only live when the flag is
+ * on, else clicking would bounce to /admin/dashboard. Galleries/events have
+ * no such gate. */
+const FLAG_FOR_KIND: Partial> = {
+ quote: 'quotes',
+ contract: 'contracts',
+ invoice: 'bills',
+};
+
/** 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 {
+ * (hours have no page; emails open the preview) OR the destination's feature
+ * flag is off (so we don't render a link that just redirects away). */
+function hrefFor(
+ kind: FeedKind | ProjectMilestone['kind'],
+ id: number | undefined,
+ flags: Record,
+): string | null {
if (id == null) return null;
+ const flag = FLAG_FOR_KIND[kind as FeedKind];
+ if (flag && !flags[flag]) return null;
switch (kind) {
case 'quote': return `/admin/clients/quotes/${id}`;
case 'contract': return `/admin/clients/contracts/${id}`;
@@ -76,6 +95,7 @@ export const ProjectCockpitPage: React.FC = () => {
const projectId = id ? parseInt(id, 10) : null;
const navigate = useNavigate();
const qc = useQueryClient();
+ const { flags } = useFeatureFlags();
const { format, formatTime } = useLocalizedDate();
const [editName, setEditName] = useState(null);
@@ -162,14 +182,14 @@ export const ProjectCockpitPage: React.FC = () => {
key: `quote-${q.id}`, kind: 'quote', date: q.issue_date,
title: t('projects.feed.quote', 'Quote') + ` ${q.quote_number}`,
status: q.status, amount: formatMoneyMinor(Number(q.total_amount_minor), q.currency),
- href: hrefFor('quote', q.id),
+ href: hrefFor('quote', q.id, flags),
});
}
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, href: hrefFor('contract', c.id),
+ status: c.status, href: hrefFor('contract', c.id, flags),
});
}
for (const inv of data.invoices) {
@@ -177,14 +197,14 @@ export const ProjectCockpitPage: React.FC = () => {
key: `invoice-${inv.id}`, kind: 'invoice', date: inv.issue_date,
title: t('projects.feed.invoice', 'Invoice') + ` ${inv.invoice_number}`,
status: inv.status, amount: formatMoneyMinor(Number(inv.total_amount_minor), inv.currency),
- href: hrefFor('invoice', inv.id),
+ href: hrefFor('invoice', inv.id, flags),
});
}
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, href: hrefFor('gallery', ev.id),
+ subtitle: ev.slug, href: hrefFor('gallery', ev.id, flags),
});
}
for (const h of data.hours.entries) {
@@ -199,7 +219,7 @@ export const ProjectCockpitPage: React.FC = () => {
const db = b.date ? new Date(b.date).getTime() : 0;
return db - da;
});
- }, [data, t]);
+ }, [data, t, flags]);
if (isLoading) return ;
if (!data) return {t('projects.notFound', 'Project not found')}
;
@@ -309,7 +329,7 @@ export const ProjectCockpitPage: React.FC = () => {
{milestones.map((m, i) => {
const Icon = KIND_ICON[m.kind] || FileText;
- const href = hrefFor(m.kind, m.id);
+ const href = hrefFor(m.kind, m.id, flags);
return (
Date: Sat, 6 Jun 2026 23:17:29 +0200
Subject: [PATCH 17/29] fix(projects): make the whole email row clickable
(opens preview)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Document rows navigate on click, but email rows only had clickable action
buttons — the row itself was dead, which read as inconsistent. The whole
email row now opens the preview; the action buttons stopPropagation so
Resend/Cancel/Retry/Send-now still fire without also opening the preview.
Every actionable feed row is now uniformly clickable.
---
.../src/pages/admin/projects/ProjectCockpitPage.tsx | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index e3088830..9b064e64 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -357,11 +357,17 @@ export const ProjectCockpitPage: React.FC = () => {
{feed.map((item) => {
const Icon = KIND_ICON[item.kind];
+ // Whole-row click: documents navigate to their detail page,
+ // emails open the preview (so the row behaves like its buttons,
+ // not a dead strip next to them). Hours have neither → static.
+ const onRowClick = item.href
+ ? () => navigate(item.href as string)
+ : (item.kind === 'email' && item.emailId != null ? () => openPreview(item.emailId as number) : undefined);
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' : ''}`}
+ onClick={onRowClick}
+ className={`flex items-start gap-3 rounded-lg border border-neutral-100 dark:border-neutral-800 px-3 py-2 ${onRowClick ? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/60' : ''}`}
>
@@ -378,7 +384,7 @@ export const ProjectCockpitPage: React.FC = () => {
)}
{item.amount &&
{item.amount} }
{item.kind === 'email' && item.emailId != null && (
-
+
e.stopPropagation()}>
openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
{t('projects.email.preview', 'Preview')}
From b9a9c018c0d2e0ff6c78ce0a1419ac28449043a4 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:25:29 +0200
Subject: [PATCH 18/29] fix(projects): render email preview with its own brand
colors, not forced light
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The email wrapper already sets body/container/text backgrounds from the
brand email-theme settings (email_body_bg_color etc.), so a dark preview is
the configured design — forcing it light was wrong. Render the email as-is;
set the iframe color-scheme to 'normal' only so the admin's dark app theme
doesn't leak into the iframe's UA defaults. The brand's light/dark choice is
respected.
---
.../src/pages/admin/projects/ProjectCockpitPage.tsx | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index 9b064e64..4875f25e 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -445,7 +445,16 @@ export const ProjectCockpitPage: React.FC = () => {
{t('projects.email.reRendered', 'Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.')}
)}
-
+ {/* Render the email exactly as sent — it carries its own
+ background from the brand/email theme. isolate the
+ iframe's color-scheme so the admin's OS dark mode doesn't
+ tint a document that defines its own colors. */}
+
>
) : (
From fa622cf2f87f447952b2e6e6e9d7fe60a1c14fb7 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sat, 6 Jun 2026 23:45:54 +0200
Subject: [PATCH 19/29] fix(projects): make email preview fully read-only (no
clickable links)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Inside the preview iframe the Accept button navigated but other links didn't
— inconsistent, and worse, clicking Accept/Decline would hit the live action
URLs and change the quote state. Sandbox the iframe (no popups/scripts/forms)
and force all anchors to target=_blank so every link is inert. Now nothing in
the preview is clickable (consistent + safe); scrolling and brand colors are
unaffected.
---
.../admin/projects/ProjectCockpitPage.tsx | 23 +++++++++++++++----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index 4875f25e..61da0582 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -83,6 +83,16 @@ const KIND_ICON: Record> =
hours: Clock,
};
+/** Neutralise links for a read-only preview: force every anchor to target a
+ * new tab so the sandboxed iframe (no allow-popups) blocks the navigation
+ * entirely. Without this, clicking "Accept"/"Decline" in the preview would
+ * hit the live action URLs and actually change the quote's state. */
+function neutralizeLinks(html: string): string {
+ const base = ' ';
+ if (/]*>/i.test(html)) return html.replace(/]*>/i, (m) => m + base);
+ return base + html;
+}
+
function minutesToHours(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
@@ -445,13 +455,16 @@ export const ProjectCockpitPage: React.FC = () => {
{t('projects.email.reRendered', 'Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.')}
)}
- {/* Render the email exactly as sent — it carries its own
- background from the brand/email theme. isolate the
- iframe's color-scheme so the admin's OS dark mode doesn't
- tint a document that defines its own colors. */}
+ {/* Read-only preview: renders the email with its own brand
+ colors (color-scheme:normal stops the dark app theme from
+ tinting it), but `sandbox` (no allow-popups/scripts/forms)
+ + neutralizeLinks make every link inert — so the admin
+ can't accidentally trigger the live Accept/Decline URLs by
+ clicking inside the preview. Scrolling still works. */}
From f71243e388e12da840aa70193b4907af2866c039 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:10:07 +0200
Subject: [PATCH 20/29] fix(projects): use real events.edit permission for
project writes
The project create/update/assign routes required 'events.manage', which is
not a real permission (the event perms are view/create/edit/delete/archive).
Since it's absent from the permissions table, even super_admin's all-perms
set excluded it, so every write 403'd with 'Insufficient permissions'.
Switched the write routes to the existing 'events.edit'. (Reads keep
events.view; cockpit doc gating + email actions already use real keys.)
---
backend/src/routes/adminProjects.js | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js
index b5ae2dee..66943423 100644
--- a/backend/src/routes/adminProjects.js
+++ b/backend/src/routes/adminProjects.js
@@ -49,7 +49,7 @@ router.get('/', requirePermission('events.view'), handleAsync(async (req, res) =
// Create
router.post('/',
- requirePermission('events.manage'),
+ requirePermission('events.edit'),
[body('name').isString().trim().isLength({ min: 1, max: 255 }), body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
@@ -71,7 +71,7 @@ router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1
// Update
router.put('/:id',
- requirePermission('events.manage'),
+ requirePermission('events.edit'),
[
param('id').isInt({ min: 1 }),
body('name').optional().isString().trim().isLength({ min: 1, max: 255 }),
@@ -91,7 +91,7 @@ router.put('/:id',
// Attach an event to the project
router.post('/:id/events',
- requirePermission('events.manage'),
+ requirePermission('events.edit'),
[param('id').isInt({ min: 1 }), body('eventId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
@@ -102,7 +102,7 @@ router.post('/:id/events',
// Attach a quote to the project (quotes carry no event_id — migration 121).
router.post('/:id/quotes',
- requirePermission('events.manage'),
+ requirePermission('events.edit'),
[param('id').isInt({ min: 1 }), body('quoteId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
@@ -113,7 +113,7 @@ router.post('/:id/quotes',
// Attach a contract to the project.
router.post('/:id/contracts',
- requirePermission('events.manage'),
+ requirePermission('events.edit'),
[param('id').isInt({ min: 1 }), body('contractId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
From 0cc52f36938be2dc6c8a699afdc55d70831c02a9 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:21:26 +0200
Subject: [PATCH 21/29] fix(projects): wrap long URLs in email preview (no
horizontal scroll)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A long unbreakable token (e.g. the gallery link) overflowed the email
container and forced the admin to side-scroll. The preview HTML prep now
also injects overflow-wrap:break-word so long words/URLs wrap within the
container. break-word only triggers on overflow, so table layout is
unaffected. (Renamed neutralizeLinks → preparePreviewHtml.)
---
.../admin/projects/ProjectCockpitPage.tsx | 20 ++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
index 61da0582..5e7dc3ab 100644
--- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
+++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx
@@ -83,14 +83,16 @@ const KIND_ICON: Record
> =
hours: Clock,
};
-/** Neutralise links for a read-only preview: force every anchor to target a
- * new tab so the sandboxed iframe (no allow-popups) blocks the navigation
- * entirely. Without this, clicking "Accept"/"Decline" in the preview would
- * hit the live action URLs and actually change the quote's state. */
-function neutralizeLinks(html: string): string {
- const base = ' ';
- if (/]*>/i.test(html)) return html.replace(/]*>/i, (m) => m + base);
- return base + html;
+/** Prepare the rendered email for a read-only preview:
+ * - ` ` so the sandboxed iframe (no allow-popups) blocks
+ * every link, preventing accidental clicks on the live Accept/Decline URLs.
+ * - `overflow-wrap:break-word` so a long unbreakable token (e.g. the gallery
+ * link) wraps inside the container instead of forcing horizontal scroll.
+ * break-word only kicks in on overflow, so it won't disturb table layout. */
+function preparePreviewHtml(html: string): string {
+ const inject = ' ';
+ if (/]*>/i.test(html)) return html.replace(/]*>/i, (m) => m + inject);
+ return inject + html;
}
function minutesToHours(min: number): string {
@@ -463,7 +465,7 @@ export const ProjectCockpitPage: React.FC = () => {
clicking inside the preview. Scrolling still works. */}