diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 2b81f076..82b8383d 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.60.6-beta.0" + ".": "3.61.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fdab2f..4ad3f26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.61.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.6-beta.0...v3.61.0-beta.0) (2026-06-13) + + +### Features + +* **projects:** Project Overview cockpit — link (multiple) quotes/contracts/hours into projects ([58f93ae](https://github.com/the-luap/picpeak/commit/58f93ae71350cc4a100f15a1a11f478750dace91)) + + +### Bug Fixes + +* **projects:** "one customer matches" rule for deal-lineage attach ([f74d8d4](https://github.com/the-luap/picpeak/commit/f74d8d4e8cd9fa040e067ffd751b183a9673161b)) +* **projects:** address review — cross-customer guards + email/queue hardening ([9d13880](https://github.com/the-luap/picpeak/commit/9d13880f2b177a1a090c4685798e199a1f47b5ec)) +* **projects:** enforce single-customer projects (guard event attach + re-label) ([4b1e85c](https://github.com/the-luap/picpeak/commit/4b1e85c8555b03cbed4abbd80e9cb45b831df6bf)) + ## [3.60.6-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.5-beta.0...v3.60.6-beta.0) (2026-06-10) diff --git a/backend/migrations/core/117_add_projects.js b/backend/migrations/core/117_add_projects.js new file mode 100644 index 00000000..e49cbf45 --- /dev/null +++ b/backend/migrations/core/117_add_projects.js @@ -0,0 +1,90 @@ +/** + * 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. Wrapped in a + // single transaction so a heavy install (10k+ events) can't be left + // half-assigned if the loop dies mid-way — it's all-or-nothing, and a + // re-run still no-ops (gated on whereNull('project_id')). + if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) { + const hasAssignments = await knex.schema.hasTable('event_customer_assignments'); + await knex.transaction(async (trx) => { + const events = await trx('events').whereNull('project_id').select('id', 'event_name'); + for (const ev of events) { + let customerId = null; + if (hasAssignments) { + const rows = await trx('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 trx('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 trx('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'); + }); + } +}; 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/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/package.json b/backend/package.json index a97036ed..f737c258 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.60.6-beta.0", + "version": "3.61.0-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { diff --git a/backend/server.js b/backend/server.js index ee4c1b3d..b1ddcf70 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/middleware/maintenance.js b/backend/src/middleware/maintenance.js index e3be0e0e..37ffb202 100644 --- a/backend/src/middleware/maintenance.js +++ b/backend/src/middleware/maintenance.js @@ -64,10 +64,15 @@ async function checkMaintenanceMode() { // Middleware to enforce maintenance mode async function maintenanceMiddleware(req, res, next) { - // Skip maintenance check for certain paths + // Skip maintenance check for certain paths. Admin auth MUST work during + // maintenance — otherwise enabling it locks every admin out, including + // already-logged-in ones (their /auth/session check would 503 and read as + // logged-out). These are the REAL endpoints: the admin login + session + // routes live under /api/auth, NOT /api/admin (the old /api/admin/login + // entries here matched nothing, which is exactly why the lockout happened). const skipPaths = [ - '/api/admin/login', - '/api/admin/auth/login', + '/api/auth/admin/login', + '/api/auth/session', '/api/public/settings', '/health' ]; 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/adminCustomers.js b/backend/src/routes/adminCustomers.js index 0d3c81e7..95c43ec5 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -165,7 +165,7 @@ router.post('/invite', [ body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }), body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }), body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }), - body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }), + body('prefill.country_code').optional({ values: 'falsy' }).isLength({ min: 2, max: 2 }).isAlpha().withMessage('country_code must be a 2-letter ISO code').customSanitizer((v) => (v || '').toUpperCase()), // Per-customer preferred language. Drives portal UI + quote/invoice // PDF locale. Defaults at insert time to the business profile's // default_locale when the admin doesn't supply one (see @@ -246,7 +246,7 @@ router.post('/', [ body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }), body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }), body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }), - body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }), + body('prefill.country_code').optional({ values: 'falsy' }).isLength({ min: 2, max: 2 }).isAlpha().withMessage('country_code must be a 2-letter ISO code').customSanitizer((v) => (v || '').toUpperCase()), body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }), body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }), // At least one human-readable identifier so the record isn't a @@ -379,7 +379,7 @@ router.put('/:id', [ body('postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }), body('city').optional({ nullable: true }).isString().isLength({ max: 120 }), body('state').optional({ nullable: true }).isString().isLength({ max: 120 }), - body('country_code').optional({ nullable: true }).isString().isLength({ max: 2 }), + body('country_code').optional({ values: 'falsy' }).isLength({ min: 2, max: 2 }).isAlpha().withMessage('country_code must be a 2-letter ISO code').customSanitizer((v) => (v || '').toUpperCase()), body('country_name').optional({ nullable: true }).isString().isLength({ max: 120 }), body('preferred_language').optional({ nullable: true }).isString().isLength({ max: 8 }), body('notes').optional({ nullable: true }).isString(), @@ -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/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 3f655b26..caf0eab7 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -76,6 +76,10 @@ const KNOWN_FLAGS = [ // Expenses (migration 127) — internal expenses (mileage / per-diem / cash). // Separate Accounting sub-feature; forced off when `accounting` is off. 'expenses', + // 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 @@ -102,6 +106,7 @@ const DEFAULT_FLAGS = { accounting: false, incomingInvoices: false, expenses: false, + projects: false, }; async function readAllFlags() { @@ -146,6 +151,8 @@ function applyDependencyRules(flags) { || out.contracts // NOTE: taxReport intentionally removed — Tax export moved to the // Accounting section (its own master), no longer a CRM sub-feature. + // 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/adminInvoices.js b/backend/src/routes/adminInvoices.js index b114bf44..23d8f2bf 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -32,6 +32,23 @@ const { db } = require('../database/db'); const router = express.Router(); +// PR #603 review follow-up #2 — bound payment dates. `isISO8601()` alone +// accepts year 1900/9999; cash-basis revenue keys on paid_at, so a typo +// (2026→2226) would silently push a payment out of every dashboard window +// forever. Reject anything before 2000-01-01 or more than 30 days in the +// future (small future window covers value-date lag without allowing fat- +// finger years). Use as `.custom(isReasonablePaidAt)` after `.isISO8601()`. +function isReasonablePaidAt(value) { + const d = new Date(value); + if (Number.isNaN(d.getTime())) throw new Error('Invalid payment date'); + const min = new Date('2000-01-01T00:00:00Z'); + const max = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + if (d < min || d > max) { + throw new Error('Payment date must be between 2000-01-01 and 30 days from now'); + } + return true; +} + // Multer config for "import historical invoice" PDF uploads. Stored // under storage/business-docs/invoice-imports// so // imported files don't collide with the renderer's own output under @@ -438,7 +455,7 @@ router.post( body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']), body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), - body('paidAt').optional({ values: 'falsy' }).isISO8601(), + body('paidAt').optional({ values: 'falsy' }).isISO8601().custom(isReasonablePaidAt), body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), ], handleAsync(async (req, res) => { @@ -745,7 +762,7 @@ router.post( [ param('id').isInt({ min: 1 }), body('amountMinor').isInt({ min: 1 }), - body('paidAt').optional({ values: 'falsy' }).isISO8601(), + body('paidAt').optional({ values: 'falsy' }).isISO8601().custom(isReasonablePaidAt), body('paymentMethod').optional({ values: 'falsy' }).isString().isLength({ max: 64 }), body('reference').optional({ values: 'falsy' }).isString().isLength({ max: 128 }), body('notes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js new file mode 100644 index 00000000..c7e47f0b --- /dev/null +++ b/backend/src/routes/adminProjects.js @@ -0,0 +1,160 @@ +/** + * 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 { 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) => { + // Value rollup mirrors the cockpit's per-doc gating so the list never + // shows figures the admin lacks permission to see. Only invoices + quotes + // carry monetary totals; contracts contribute none, so (unlike the detail + // route, which gates the contracts *section*) the list needs no contracts + // permission. + 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 }); +})); + +// Create +router.post('/', + 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); + 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.edit'), + [ + param('id').isInt({ min: 1 }), + body('name').optional().isString().trim().isLength({ min: 1, max: 255 }), + // nullable:true → accept JSON `null` (clear the customer); isInt otherwise. + body('customerAccountId').optional({ nullable: true }).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.edit'), + [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'); + }), +); + +// Attach a quote to the project (quotes carry no event_id — migration 121). +router.post('/:id/quotes', + requirePermission('events.edit'), + [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.edit'), + [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); + 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); +})); + +// 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), req.admin.id); + 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/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..54af7e41 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -803,7 +803,14 @@ 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'); + if (row.project_id && row.deal_uuid) { + await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx); + } const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; // Seed with every active system block, toggled on. Per-section @@ -890,8 +897,18 @@ 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); + // Cascade across the deal lineage (linked quote / event / invoices). + if (updates.project_id) { + const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first(); + await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx); + } + // Replace inclusions only when the caller sent an explicit list. // (Editor's "save" sends every row; an inline "toggle" save could // send a partial update — current frontend always sends full list.) diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index 2ce2e262..cbcd792a 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -233,6 +233,21 @@ async function createEntry(customerId, payload, adminId) { created_at: new Date(), updated_at: new Date(), }; + // Migration 118 — optional "book to project" link. A project belongs to + // at most one customer, so reject booking hours onto a project owned by a + // DIFFERENT customer (defence-in-depth behind the customer-scoped picker). + // Unassigned projects (customer_account_id null) are allowed for anyone. + if (payload.projectId !== undefined && await hasColumnCached('customer_hour_entries', 'project_id')) { + const projectId = payload.projectId || null; + if (projectId && await trx.schema.hasTable('projects')) { + const project = await trx('projects').where({ id: projectId }).select('customer_account_id').first(); + if (!project) throw new AppError('Project not found', 404, 'PROJECT_NOT_FOUND'); + if (project.customer_account_id != null && project.customer_account_id !== customer.id) { + throw new AppError('That project belongs to a different customer', 422, 'PROJECT_CUSTOMER_MISMATCH'); + } + } + row.project_id = projectId; + } 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/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 0e9b2412..42a454aa 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -38,6 +38,13 @@ async function initializeTransporter(forceReinit = false) { // Configuration has changed or first initialization logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : '')); + // PR #603 review follow-up #3 — release the previous transporter before + // swapping it. Harmless today (no connection pool), but prevents a + // socket/connection leak if `pool: true` is ever enabled on the transport. + if (transporter && typeof transporter.close === 'function') { + try { transporter.close(); } catch (_) { /* best-effort */ } + } + transporter = nodemailer.createTransport({ host: config.smtp_host, port: config.smtp_port, @@ -252,6 +259,19 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') { const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`; logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl }); + const year = new Date().getFullYear(); + // PR review follow-up — Outlook (Word engine) and Apple Mail under some + // configs STRIP the - - + + + + + + `; } @@ -733,13 +763,31 @@ 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; } } +/** + * 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: @@ -750,9 +798,13 @@ async function sendTemplateEmail(to, templateKey, variables) { // limit max emails per pass. The flush raises this to drain the // whole queue in a single pass (no re-query, so a failing // email isn't retried in a tight loop within one flush). +// onlyId when set, process EXACTLY this one queue row. Used by the +// cockpit "send now" so a forced send never sweeps up OTHER +// dead-lettered emails (those past the retry cap) just +// because ignoreSchedule also bypasses that cap. // // Returns { processed, sent, failed }. -async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) { +async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = null } = {}) { logger.info('Email queue processor: Checking for pending emails...'); const result = { processed: 0, sent: 0, failed: 0 }; @@ -775,6 +827,9 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) { const now = new Date(); const query = db('email_queue') .where('status', 'pending'); + // Targeted single-email flush (cockpit "send now"): scope to that row + // only, so we never force-retry other dead-lettered emails. + if (onlyId != null) query.where('id', onlyId); if (!ignoreSchedule) { // Automatic runs: respect the retry cap (don't hammer a failing // address) AND the schedule (business-hours floor / future send). @@ -809,19 +864,24 @@ 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 { + 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`); @@ -886,7 +946,18 @@ async function getScheduledEmailConfig() { const schedule = normaliseSchedule(profile.business_hours); let timezone = (profile.timezone || '').trim(); - if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + if (!timezone) { + // PR #603 review follow-up #4 — business hours are configured but the + // profile timezone is blank, so we fall back to the SERVER's tz (usually + // UTC on a Docker host). That silently shifts every business-hours + // calculation. Warn loudly so the admin sets business_profile.timezone. + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + logger.warn( + 'Scheduled-email business hours are set but business_profile.timezone is blank — ' + + `falling back to the server timezone (${timezone}). Set the profile timezone ` + + 'so business-hours snapping uses your local time, not the server\'s.', + ); + } // Reject a bogus tz before it reaches Intl in the snap helper. try { new Intl.DateTimeFormat('en-US', { timeZone: timezone }); @@ -1025,6 +1096,7 @@ module.exports = { initializeTransporter, startEmailQueueProcessor, sendTemplateEmail, + renderQueuedEmail, processEmailQueue, queueEmail, stopEmailQueueProcessor, diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 91a92e68..4f708097 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -694,6 +694,22 @@ async function createInvoice(payload, adminId, trx = db) { const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first(); ensureCustomerCanBill(customer); + // PR #603 review follow-up #1 — when an invoice is attached to an event, + // make sure that event actually belongs to the chosen customer. Without + // this, a typo'd/copy-pasted eventId silently links the invoice to an + // unrelated event, producing misleading reporting links. Only enforced + // when the event HAS customer assignments (an event with none — e.g. a + // legacy import — is allowed through, since we can't prove a mismatch). + if (payload.eventId && await trx.schema.hasTable('event_customer_assignments')) { + const assignments = await trx('event_customer_assignments') + .where({ event_id: payload.eventId }) + .select('customer_account_id'); + if (assignments.length > 0 && + !assignments.some(a => a.customer_account_id === payload.customerAccountId)) { + throw new AppError('The selected event is not assigned to this customer', 422, 'EVENT_CUSTOMER_MISMATCH'); + } + } + // Accumulator intercept (migration 128). For customers in // billing_cadence='monthly' OR 'manual' mode every createInvoice call // APPENDS line items onto a single running draft instead of minting a diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js new file mode 100644 index 00000000..057bd7ab --- /dev/null +++ b/backend/src/services/projectService.js @@ -0,0 +1,653 @@ +/** + * 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, logActivity } = require('../database/db'); +const { AppError } = require('../utils/errors'); +const { hasColumnCached } = require('../utils/schemaCache'); + +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 + 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( + '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; + 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'); + } else if (perms.quotes !== false && await hasColumnCached('quotes', 'customer_account_id')) { + // Pre-121 fallback: no quotes.project_id column yet. Scope quotes by the + // project's customer (mirrors the detail page) so the list isn't all-zero + // during the upgrade window. Imprecise when one customer owns several + // projects — each then shows the customer's full quote total — but never + // zero. Goes away the moment migration 121 lands. + const custToProjects = new Map(); + for (const p of projects) { + if (p.customerAccountId == null) continue; + const list = custToProjects.get(p.customerAccountId) || []; + list.push(p.id); custToProjects.set(p.customerAccountId, list); + } + if (custToProjects.size) { + const rows = await db('quotes') + .whereIn('customer_account_id', Array.from(custToProjects.keys())) + .select('customer_account_id', 'id', 'deal_uuid', 'total_amount_minor', 'currency', 'issue_date'); + for (const r of rows) { + for (const pid of (custToProjects.get(r.customer_account_id) || [])) { + quotes.push({ ...r, project_id: pid }); + } + } + } + } + + 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) { + 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); +} + +/** Distinct customer_account_ids referenced by a project's linked content + * (its events, quotes and contracts). Used to keep a project single-customer: + * re-labelling it to a customer that conflicts with existing content is + * rejected. */ +async function projectLinkedCustomerIds(id, conn = db) { + const ids = new Set(); + if (await hasColumnCached('events', 'project_id') && await conn.schema.hasTable('event_customer_assignments')) { + const rows = await conn('event_customer_assignments as eca') + .join('events as e', 'e.id', 'eca.event_id') + .where('e.project_id', id) + .distinct('eca.customer_account_id as cid'); + for (const r of rows) if (r.cid != null) ids.add(Number(r.cid)); + } + for (const tbl of ['quotes', 'contracts']) { + if (await hasColumnCached(tbl, 'project_id') && await hasColumnCached(tbl, 'customer_account_id')) { + for (const r of await conn(tbl).where({ project_id: id }).whereNotNull('customer_account_id').distinct('customer_account_id as cid')) { + if (r.cid != null) ids.add(Number(r.cid)); + } + } + } + return ids; +} + +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) { + const next = customerAccountId || null; + // Single-customer invariant: don't re-label a project to a customer that + // conflicts with documents/events it already holds. Clearing (null) is fine. + if (next != null) { + const linked = await projectLinkedCustomerIds(id); + for (const cid of linked) { + if (cid !== Number(next)) { + throw new AppError('This project already contains another customer’s content — clear or move it before reassigning the customer', 422, 'PROJECT_CUSTOMER_MISMATCH'); + } + } + } + patch.customer_account_id = next; + } + if (status !== undefined) patch.status = status; + await db('projects').where({ id }).update(patch); + return getProjectById(id); +} + +/** Distinct customer_account_ids an event is assigned to (event_customer_assignments + * is many-to-many, but a gallery normally belongs to exactly one account). */ +async function eventCustomerIds(eventId, conn = db) { + if (!(await conn.schema.hasTable('event_customer_assignments'))) return []; + const rows = await conn('event_customer_assignments') + .where({ event_id: eventId }) + .distinct('customer_account_id'); + return rows.map((r) => r.customer_account_id).filter((x) => x != null).map(Number); +} + +/** Attach an event to a project (re-points events.project_id). + * Projects are single-customer: an event may only join a project that shares + * its customer. When the project has no customer yet it ADOPTS the event's + * (single) customer — keeping the whole project tied to one customer. */ +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); + + const evCustomers = await eventCustomerIds(eventId); + if (project.customer_account_id != null) { + if (evCustomers.length && !evCustomers.includes(Number(project.customer_account_id))) { + throw new AppError('That event belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH'); + } + } else if (evCustomers.length === 1) { + // Empty project adopts the event's single customer (first content wins). + await db('projects').where({ id: projectId }).update({ customer_account_id: evCustomers[0], updated_at: new Date() }); + } + + await db('events').where({ id: eventId }).update({ project_id: projectId }); + return { projectId, eventId }; +} + +/** + * Cascade a project link across a whole deal's lineage. Given a deal_uuid, link + * every quote + contract in that deal to the project, re-point every event the + * deal produced (so its invoices / emails / gallery roll up automatically), and + * adopt the deal's customer onto the project when it has none. This is what + * makes "drop a quote on an empty project" fill the cockpit with the linked + * contract, event and invoices. Idempotent; pass a trx to run inside a txn. + */ +async function linkDealToProject(dealUuid, projectId, conn = db) { + if (!dealUuid || !projectId) return; + + // Collect ALL the deal's customers across its quote/contract/invoice lineage + // AND every event it converted into — BEFORE mutating anything, so a link + // with no matching customer is rejected before we re-point data across tenants. + const eventIds = new Set(); + const dealCustomerIds = new Set(); + const quotesHaveDeal = await hasColumnCached('quotes', 'deal_uuid'); + if (quotesHaveDeal) { + for (const q of await conn('quotes').where({ deal_uuid: dealUuid }).select('converted_event_id', 'customer_account_id')) { + if (q.converted_event_id) eventIds.add(q.converted_event_id); + if (q.customer_account_id != null) dealCustomerIds.add(Number(q.customer_account_id)); + } + } + const contractsHaveDeal = await hasColumnCached('contracts', 'deal_uuid'); + if (contractsHaveDeal && await hasColumnCached('contracts', 'converted_event_id')) { + for (const c of await conn('contracts').where({ deal_uuid: dealUuid }).select('converted_event_id', 'customer_account_id')) { + if (c.converted_event_id) eventIds.add(c.converted_event_id); + if (c.customer_account_id != null) dealCustomerIds.add(Number(c.customer_account_id)); + } + } + if (await hasColumnCached('invoices', 'deal_uuid')) { + for (const inv of await conn('invoices').where({ deal_uuid: dealUuid }).select('event_id', 'customer_account_id')) { + if (inv.event_id) eventIds.add(inv.event_id); + if (inv.customer_account_id != null) dealCustomerIds.add(Number(inv.customer_account_id)); + } + } + + // Single-customer projects, "one customer matches" rule: a customer-assigned + // project rejects the deal only when NONE of the deal's customers is the + // project's customer. An *unassigned* project (customer_account_id null) + // ADOPTS the deal's customer below — "drop the first deal on an empty project". + const project = await conn('projects').where({ id: projectId }).select('customer_account_id').first(); + if (!project) throw new AppError('Project not found', 404, 'PROJECT_NOT_FOUND'); + if ( + project.customer_account_id != null && + dealCustomerIds.size && + !dealCustomerIds.has(Number(project.customer_account_id)) + ) { + throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH'); + } + + // Cleared to write: link the deal's quotes/contracts, re-point its events so + // invoices/emails/gallery roll up automatically. + if (quotesHaveDeal && await hasColumnCached('quotes', 'project_id')) { + await conn('quotes').where({ deal_uuid: dealUuid }).update({ project_id: projectId }); + } + if (contractsHaveDeal && await hasColumnCached('contracts', 'project_id')) { + await conn('contracts').where({ deal_uuid: dealUuid }).update({ project_id: projectId }); + } + if (eventIds.size && await hasColumnCached('events', 'project_id')) { + await conn('events').whereIn('id', Array.from(eventIds)).update({ project_id: projectId }); + } + + // Adopt the deal's customer onto a still-unassigned project (first deal wins). + if (dealCustomerIds.size && project.customer_account_id == null) { + const adopt = [...dealCustomerIds][0]; + await conn('projects').where({ id: projectId }).update({ customer_account_id: adopt, updated_at: new Date() }); + } +} + +/** Attach (or, with projectId=null, detach) a quote/contract to a project. + * Attaching cascades the link across the deal lineage (see linkDealToProject). */ +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); + } + let project = null; + if (projectId != null) { + 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); + // Single-customer guard: a document carries exactly one customer, so it may + // only attach to a project that shares it. linkDealToProject re-checks the + // wider deal lineage ("one customer matches"); this is the boundary check + // that also covers the (unassigned-project) detach and standalone-doc cases. + if ( + project && + project.customer_account_id != null && + doc.customer_account_id != null && + project.customer_account_id !== doc.customer_account_id + ) { + throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH'); + } + await db(table).where({ id: documentId }).update({ project_id: projectId || null }); + if (projectId && doc.deal_uuid) { + await linkDealToProject(doc.deal_uuid, projectId); + } + return { projectId: projectId || null, 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 + * 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 } }; + + // 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. 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; + } + + // Emails — newest first. rendered_html presence flagged; body fetched lazily + // by the preview endpoint. Two precisely-scoped sources, never the recipient + // string alone (a shared family inbox must NOT leak another customer's mail): + // 1. Gallery/event mails — carry event_id, and the events belong to this + // project, so whereIn(eventIds) is already exact. + // 2. CRM document mails (quote_/contract_/invoice_/storno_) — queued with + // event_id=null + recipient=customer email. We use the recipient only as + // a cheap candidate filter, then KEEP a row only when its email_data + // document number matches one of THIS project's loaded documents. That + // both scopes to the right customer and excludes system/admin alerts + // (backup_failed, …) sent to the same inbox. + const selectCols = ['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')]; + const mapEmail = (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), + }); + + const emailRows = []; + if (eventIds.length) { + const eventEmails = await db('email_queue') + .whereIn('event_id', eventIds) + .select(selectCols) + .orderBy('created_at', 'desc') + .limit(200); + emailRows.push(...eventEmails); + } + const customerEmail = project.customerEmail || null; + // The set of document numbers that belong to this project (across the doc + // types the admin may see). CRM emails carry their number in email_data. + const docNumbers = new Set(); + for (const q of out.quotes) if (q.quote_number != null) docNumbers.add(String(q.quote_number)); + for (const c of out.contracts) if (c.contract_number != null) docNumbers.add(String(c.contract_number)); + for (const inv of out.invoices) if (inv.invoice_number != null) docNumbers.add(String(inv.invoice_number)); + if (customerEmail && docNumbers.size) { + const crmCandidates = await db('email_queue') + .where('recipient_email', customerEmail) + .whereNull('event_id') + .andWhere(function () { + // LIKE '_' is a single-char wildcard matching the literal underscore in + // every CRM type; no escape needed and no real type collides with '%'. + for (const prefix of ['quote_%', 'contract_%', 'invoice_%', 'storno_%']) { + this.orWhere('email_type', 'like', prefix); + } + }) + .select([...selectCols, 'email_data']) + .orderBy('created_at', 'desc') + .limit(200); + for (const r of crmCandidates) { + let data = r.email_data; + if (typeof data === 'string') { try { data = JSON.parse(data); } catch (_) { data = {}; } } + data = data || {}; + // Match on any document-number key a CRM template carries (storno mails + // use storno_number / original_invoice_number, not invoice_number). + const candidates = [data.quote_number, data.contract_number, data.invoice_number, + data.storno_number, data.original_invoice_number]; + if (candidates.some((n) => n != null && docNumbers.has(String(n)))) emailRows.push(r); + } + } + // Merge both sources, newest first, capped. + emailRows.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + out.emails = emailRows.slice(0, 200).map(mapEmail); + + // 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.at(-1); + if (firstQuote) milestones.push({ kind: 'quote', id: firstQuote.id, label: firstQuote.quote_number, date: firstQuote.issue_date }); + const firstContract = out.contracts.at(-1); + 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', id: pubEvent.id, label: pubEvent.event_name, date: pubEvent.event_date }); + const firstInvoice = out.invoices.at(-1); + 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). + out.valuation = computeValuation(out.invoices, out.quotes); + + return out; +} + +/** + * 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', '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: !!html, + exact: false, + html, + }; +} + +// ── Email actions (from the cockpit feed) ─────────────────────────────── + +/** Audit every admin email action uniformly (mark-paid / cancel / reissue in + * the CRM services all log; these were the gap). Best-effort — never blocks. */ +async function logEmailAction(activityType, emailId, row, adminId) { + try { + await logActivity( + activityType, + { queueId: emailId, emailType: row && row.email_type, recipient: row && row.recipient_email }, + (row && row.event_id) || null, + adminId ? { type: 'admin', id: adminId } : null, + ); + } catch (_) { /* audit is best-effort */ } +} + +async function resendEmail(emailId, adminId = null) { + const row = await db('email_queue').where({ id: emailId }).first(); + if (!row) throw new AppError('Email not found', 404); + // Normalise email_data to match the canonical enqueue (emailProcessor.js + // stores JSON.stringify(...) in the json column). PG returns jsonb as a + // parsed object, SQLite as a string — re-stringify the object form so the + // resent row is never double-encoded. + let emailData = row.email_data; + if (emailData != null && typeof emailData !== 'string') emailData = JSON.stringify(emailData); + const insert = await db('email_queue').insert({ + recipient_email: row.recipient_email, + email_type: row.email_type, + email_data: emailData, + 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]; + await logEmailAction('project_email_resent', id, row, adminId); + return { id, status: 'pending' }; +} + +async function cancelEmail(emailId, adminId = null) { + 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' }); + await logEmailAction('project_email_cancelled', emailId, row, adminId); + return { id: emailId, status: 'cancelled' }; +} + +async function retryEmail(emailId, adminId = null) { + 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 }); + await logEmailAction('project_email_retried', emailId, row, adminId); + return { id: emailId, status: 'pending' }; +} + +async function sendEmailNow(emailId, adminId = null) { + 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 }); + // Flush ONLY this email — passing onlyId scopes processEmailQueue to a single + // row so a forced "send now" never force-retries OTHER dead-lettered emails + // (those that already exceeded the retry cap) just because we bypass it here. + const { processEmailQueue } = require('./emailProcessor'); + const result = await processEmailQueue({ ignoreSchedule: true, onlyId: emailId }); + await logEmailAction('project_email_sent_now', emailId, row, adminId); + return result; +} + +module.exports = { + listProjects, + getProjectById, + createProject, + updateProject, + assignEvent, + assignQuote, + assignContract, + linkDealToProject, + computeValuation, + getProjectOverview, + getEmailPreview, + resendEmail, + cancelEmail, + retryEmail, + sendEmailNow, +}; diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index c6304309..514864fa 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -554,9 +554,20 @@ 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]; + // Cascade the project link across the deal lineage (no-op for a brand-new + // quote with no contract/event yet — just adopts the customer onto an + // empty project). + if (row.project_id) { + await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx); + } + if (totals.lineItems.length > 0) { // Normalise rows for the hierarchical-insert helper. We preserve // the wire-only `parent_position` field here so the helper can @@ -667,8 +678,19 @@ 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); + // When linked to a project, cascade across the deal lineage so the linked + // contract / event / invoices roll up into the same project automatically. + if (updates.project_id) { + const dealRow = await trx('quotes').where({ id }).select('deal_uuid').first(); + await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx); + } + // Delete + reinsert keeps the editor flow simple: the frontend // sends the canonical line-item set on every save, we drop the // old rows and rebuild from scratch. CASCADE on parent_line_item_id diff --git a/frontend/package.json b/frontend/package.json index 05306632..3bd909b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.60.6-beta.0", + "version": "3.61.0-beta.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 759b50e0..131d14ae 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'; @@ -207,6 +209,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/MaintenanceWrapper.tsx b/frontend/src/components/MaintenanceWrapper.tsx index ccd3e159..ab9df689 100644 --- a/frontend/src/components/MaintenanceWrapper.tsx +++ b/frontend/src/components/MaintenanceWrapper.tsx @@ -1,60 +1,39 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { MaintenanceMode } from './MaintenanceMode'; import { useMaintenanceMode } from '../contexts/MaintenanceContext'; -import { setMaintenanceModeCallback, api } from '../config/api'; +import { setMaintenanceModeCallback } from '../config/api'; interface MaintenanceWrapperProps { children: React.ReactNode; } -// Maintenance detection now lives in two places: +// Maintenance detection lives in two places: // 1. The axios interceptor in config/api.ts flips the flag on any 503 response. // 2. MaintenanceContext polls /public/settings every 30s and reads the explicit // maintenance_mode field (via the shared usePublicSettings hook). -// This wrapper only needs to gate the rendered tree on the resulting state. +// +// The maintenance screen ONLY blocks customer/gallery/public routes. Admin +// routes (/admin/*) are never blocked: an admin must always be able to reach +// the panel to turn maintenance back off, and the admin auth layer already +// handles access (AdminLayout redirects a logged-out admin to /admin/login). +// Gating /admin/* here on an "is the admin logged in?" check is what caused the +// lockout — it hid the login page itself, and after login the check went stale +// (login → dashboard is a client-side nav within /admin, so it never re-ran), +// leaving a logged-in admin stuck on the maintenance screen. export const MaintenanceWrapper: React.FC = ({ children }) => { const location = useLocation(); const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode(); - const [hasAdminSession, setHasAdminSession] = useState(false); const isAdminRoute = location.pathname.startsWith('/admin'); - useEffect(() => { - let isMounted = true; - - const checkAdminSession = async () => { - if (!isAdminRoute) { - setHasAdminSession(false); - return; - } - - try { - const response = await api.get<{ valid: boolean; type: string }>('/auth/session'); - if (isMounted) { - setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin')); - } - } catch { - if (isMounted) { - setHasAdminSession(false); - } - } - }; - - checkAdminSession(); - - return () => { - isMounted = false; - }; - }, [isAdminRoute]); - useEffect(() => { setMaintenanceModeCallback((enabled: boolean) => { setMaintenanceMode(enabled); }); }, [setMaintenanceMode]); - if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) { + if (isMaintenanceMode && !isAdminRoute) { return ; } diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx index 2aca0ec5..f09115cd 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, 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/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index 6f4abd41..b871bf0e 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) => { @@ -135,6 +140,11 @@ export const HoursSection: React.FC = ({ 'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.')); return; } + if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') { + toast.error(t('projects.error.customerMismatch', + "That project belongs to a different customer than this entry.")); + return; + } const msg = err?.response?.data?.error || err?.message || t('customers.hours.error.createFailed', 'Failed to log entry'); toast.error(msg); @@ -348,6 +358,14 @@ 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/InstallmentsPanel.tsx b/frontend/src/components/admin/InstallmentsPanel.tsx index 39c2c8af..e735274c 100644 --- a/frontend/src/components/admin/InstallmentsPanel.tsx +++ b/frontend/src/components/admin/InstallmentsPanel.tsx @@ -26,10 +26,9 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Trash2, Plus } from 'lucide-react'; -import { Button, Input } from '../common'; +import { Button, Input, LocalizedDateInput } from '../common'; import type { PaymentTermInstallment } from '../../services/quotes.service'; import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults'; -import { useLocalizedDate } from '../../hooks/useLocalizedDate'; export type InstallmentPlan = PaymentTermInstallment[]; @@ -69,7 +68,6 @@ export const InstallmentsPanel: React.FC = ({ value, onChange, onValidityChange, eventDate, disabled, }) => { const { t } = useTranslation(); - const { dateInputLang } = useLocalizedDate(); const defaults = useInstallmentDefaults(); const [advanced, setAdvanced] = React.useState(false); @@ -230,12 +228,9 @@ export const InstallmentsPanel: React.FC = ({ '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/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 && ( + + )} + +
+ ); +}; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index 359196fb..34d2528f 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -56,6 +56,10 @@ export const DEFAULT_FLAGS: FeatureFlags = { // Expenses (migration 127) — internal expenses (mileage / per-diem / cash). // Separate Accounting sub-feature; requires `accounting`. expenses: 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 92af8eb9..194aaa33 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -20,6 +20,7 @@ import { Landmark, ScanLine, Wallet, + 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)} + /> {/* Accounting — top-level master + sub-toggles. The Tax export diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index b36e5ca4..f67a2f5f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1691,6 +1691,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": { @@ -3203,6 +3208,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." }, @@ -3450,6 +3456,7 @@ "body": "Aktiviere „Konten\" (oder eine andere CRM-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen." }, "subnav": { + "overview": "Übersicht", "accounts": "Konten", "quotes": "Angebote", "contracts": "Verträge", @@ -3634,6 +3641,80 @@ "createdToast": "Aufwand hinzugefügt." } }, + "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", + "value": "Wert", + "status": "Status", + "updated": "Aktualisiert" + }, + "picker": { + "label": "Projekt", + "none": "Kein Projekt" + }, + "value": { + "label": "Projektwert", + "paid": "bezahlt" + }, + "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.", + "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.", + "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", + "createFailed": "Projekt konnte nicht erstellt werden", + "saved": "Projekt gespeichert", + "saveFailed": "Speichern fehlgeschlagen", + "emailAction": "Erledigt", + "emailActionFailed": "Aktion fehlgeschlagen", + "previewFailed": "Vorschau konnte nicht geladen werden" + }, + "error": { + "customerMismatch": "Das gehört zu einem anderen Kunden als dieses Projekt." + } + }, "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 c2a455d0..8e079fb2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1249,6 +1249,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": { @@ -3203,6 +3208,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." }, @@ -3450,6 +3456,7 @@ "body": "Enable Accounts (or another CRM sub-feature) under Settings → Features to get started." }, "subnav": { + "overview": "Overview", "accounts": "Accounts", "quotes": "Quotes", "contracts": "Contracts", @@ -3634,6 +3641,80 @@ "createdToast": "Expense added." } }, + "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", + "value": "Value", + "status": "Status", + "updated": "Updated" + }, + "picker": { + "label": "Project", + "none": "No project" + }, + "value": { + "label": "Project value", + "paid": "paid" + }, + "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.", + "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.", + "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", + "createFailed": "Could not create project", + "saved": "Project saved", + "saveFailed": "Save failed", + "emailAction": "Done", + "emailActionFailed": "Action failed", + "previewFailed": "Could not load preview" + }, + "error": { + "customerMismatch": "That belongs to a different customer than this project." + } + }, "calendar": { "pageTitle": "Calendar", "subtitle": "Events, logged hours, and pending quotes/contracts in one view.", diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 6f6c749f..4fd0ee0f 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -276,6 +276,11 @@ export const AdminDashboard: React.FC = () => { const getActivityMessage = (): string => { const params: Record = { eventName: activity.eventName || t('common.unknown'), + // Customer/account activity keys (customer_login, + // customer_invitation_*, customer_updated, …) interpolate + // {{email}}; without it the literal placeholder rendered. + // Sourced the same way formatActivityMessage does. + email: activity.metadata?.email || activity.actorName || '', count: activity.metadata?.count || 0, template: activity.metadata?.template_key || '', categoryName: activity.metadata?.category_name || '' 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)} /> +
+ + {/* 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'}`); + }} + /> +
+
setEditName(e.target.value)} className="max-w-sm" /> + + +
+ )} +

+ {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) })} +

+ +
+ {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 && ( + + )} +
+ + + + {/* 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) => ( + + ))} +
+ )} +
+
+ + {/* Milestone timeline */} + {milestones && milestones.length > 0 && ( + +

{t('projects.timeline', 'Milestones')}

+
+ {milestones.map((m, i) => { + const Icon = KIND_ICON[m.kind] || FileText; + const href = hrefFor(m.kind, m.id, flags); + 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}
+
{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]; + // 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 ( +
  • + +
    +
    + {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 && ( +
    e.stopPropagation()}> + + {item.reRendered && ( + + {t('projects.email.reRenderedTag', '≈ re-rendered')} + + )} + {item.emailStatus === 'sent' && ( + + )} + {item.emailStatus === 'pending' && ( + <> + + + + )} + {item.emailStatus === 'failed' && ( + + )} +
    + )} +
    +
    +
  • + ); + })} +
+ )} +
+ + {/* Email preview modal */} + {(preview || previewLoading) && ( +
setPreview(null)}> +
e.stopPropagation()}> +
+

{t('projects.email.previewTitle', 'Email preview')}

+ +
+
+ {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.')} +
+ )} + {/* 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. */} +