Merge origin/beta into feat/accounting-inbound-invoices
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's Project Overview 'projects' flag, both registered in the same files) as additive unions — accounting + incomingInvoices + expenses AND projects all coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no collisions. Frontend build + backend node --check pass.
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.60.6-beta.0"
|
||||
".": "3.61.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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/<year>/<filename> 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 }),
|
||||
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 <head><style>, so any element styled only by a class
|
||||
// loses its design (the CTA rendered as a plain link, the header card +
|
||||
// button vanished). Fix: inline the CTA button style (themed with the
|
||||
// admin's primary colour) on every `class="button"` anchor, keeping the
|
||||
// class so style-capable clients still get :hover. The wrapper chrome
|
||||
// below is rebuilt as inline-styled tables with bgcolor attrs for the same
|
||||
// reason. The <style> block stays as progressive enhancement.
|
||||
const buttonInlineStyle = `background-color:${primaryColor};color:${buttonTextColor};display:inline-block;padding:12px 30px;text-decoration:none;border-radius:5px;font-weight:500;`;
|
||||
const inlinedBody = (typeof htmlBody === 'string' ? htmlBody : '')
|
||||
.replace(/class="button"/g, `class="button" style="${buttonInlineStyle}"`);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="${language}">
|
||||
@@ -367,22 +387,32 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<img src="${logoFullUrl}" alt="${companyName}" class="logo">
|
||||
</div>
|
||||
<div class="email-content">
|
||||
${htmlBody}
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
<img src="${logoFullUrl}" alt="${companyName}">
|
||||
<p>${companyName}</p>
|
||||
<p style="font-size: 12px; color: #999;">© ${new Date().getFullYear()} ${companyName}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body style="margin:0;padding:0;background-color:${bodyBgColor};color:${bodyTextColor};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${bodyBgColor}" style="background-color:${bodyBgColor};" class="email-wrapper">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 20px;">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" class="email-container" style="width:100%;max-width:600px;background-color:${containerBgColor};border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="${primaryColor}" class="email-header" style="background-color:${primaryColor};padding:30px;text-align:center;">
|
||||
<img src="${logoFullUrl}" alt="${companyName}" width="180" class="logo" style="max-width:180px;height:auto;display:inline-block;border:0;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="email-content" style="padding:40px 30px;">
|
||||
${inlinedBody}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" bgcolor="${secondaryColor}" class="email-footer" style="background-color:${secondaryColor};padding:30px;text-align:center;border-top:1px solid #eeeeee;">
|
||||
<img src="${logoFullUrl}" alt="${companyName}" width="120" style="max-width:120px;height:auto;opacity:0.8;margin-bottom:15px;border:0;">
|
||||
<p style="color:${mutedTextColor};font-size:14px;margin:5px 0;">${companyName}</p>
|
||||
<p style="font-size:12px;color:#999999;margin:5px 0;">© ${year} ${companyName}. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<Route path="quotes/:id" element={<QuoteDetailPage />} />
|
||||
<Route path="quotes/:id/edit" element={<QuoteEditorPage />} />
|
||||
</Route>
|
||||
{/* Project Overview (CRM) — admin-only grouping
|
||||
layer above events, gated by `projects`. */}
|
||||
<Route element={<RequireFeature flag="projects" />}>
|
||||
<Route path="projects" element={<ProjectsListPage />} />
|
||||
<Route path="projects/:id" element={<ProjectCockpitPage />} />
|
||||
</Route>
|
||||
{/* Bills / invoices (CRM) — gated by `bills`. */}
|
||||
<Route element={<RequireFeature flag="bills" />}>
|
||||
<Route path="bills" element={<BillsListPage />} />
|
||||
|
||||
@@ -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<MaintenanceWrapperProps> = ({ 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 <MaintenanceMode />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<HoursSectionProps> = ({
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [rateOverride, setRateOverride] = useState<string>('');
|
||||
const [description, setDescription] = useState('');
|
||||
// Migration 118 — optional "book to project" link (gated component).
|
||||
const [projectId, setProjectId] = useState<number | null>(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<HoursSectionProps> = ({
|
||||
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<HoursSectionProps> = ({
|
||||
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<HoursSectionProps> = ({
|
||||
'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<HoursSectionProps> = ({
|
||||
placeholder={t('customers.hours.form.notePlaceholder',
|
||||
'What was worked on?') as string} />
|
||||
</div>
|
||||
{/* Book to project — renders only when the projects feature is on. */}
|
||||
<ProjectSelect
|
||||
className="mt-3"
|
||||
label={t('customers.hours.form.bookToProject', 'Book to project') as string}
|
||||
value={projectId}
|
||||
customerAccountId={customerId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end gap-3">
|
||||
{noRateConfigured && !overrideTyped && (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
|
||||
@@ -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<InstallmentsPanelProps> = ({
|
||||
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<InstallmentsPanelProps> = ({
|
||||
'On delivery — admin releases manually. Switch to advanced to change.')}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
lang={dateInputLang}
|
||||
<LocalizedDateInput
|
||||
value={previewDate(row) || ''}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
onChange={(next) => {
|
||||
if (!next) return;
|
||||
const offset = daysBetween(todayIso(), next);
|
||||
update(idx, { trigger: 'fixed_date', offset_days: offset });
|
||||
|
||||
@@ -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<ProjectSelectProps> = ({
|
||||
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 (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value ?? ''}
|
||||
disabled={disabled || isLoading}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-60"
|
||||
>
|
||||
<option value="">{t('projects.picker.none', 'No project')}</option>
|
||||
{options.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={FolderKanban}
|
||||
title={t('settings.features.projects.title', 'Projects')}
|
||||
description={t(
|
||||
'settings.features.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.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarLabel={t('settings.features.projects.sidebar', 'Overview')}
|
||||
enabled={staged.projects}
|
||||
onToggle={(next) => setFlag('projects', next)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Accounting — top-level master + sub-toggles. The Tax export
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -276,6 +276,11 @@ export const AdminDashboard: React.FC = () => {
|
||||
const getActivityMessage = (): string => {
|
||||
const params: Record<string, any> = {
|
||||
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 || ''
|
||||
|
||||
@@ -566,8 +566,31 @@ export const BillEditorPage: React.FC = () => {
|
||||
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
|
||||
</label>
|
||||
</div>
|
||||
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
|
||||
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('bills.field.scheduledSendAt', 'Scheduled send (optional)')}</label>
|
||||
{/* 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. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<LocalizedDateInput
|
||||
value={scheduledSendAt ? scheduledSendAt.slice(0, 10) : ''}
|
||||
onChange={(iso) => {
|
||||
if (!iso) { setScheduledSendAt(''); return; }
|
||||
const time = scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : '09:00';
|
||||
setScheduledSendAt(`${iso}T${time}`);
|
||||
}}
|
||||
/>
|
||||
<TimeField
|
||||
value={scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : ''}
|
||||
onChange={(hhmm) => {
|
||||
const date = scheduledSendAt ? scheduledSendAt.slice(0, 10) : '';
|
||||
if (!date) return;
|
||||
setScheduledSendAt(`${date}T${hhmm || '09:00'}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('bills.field.qrFormat', 'Payment QR format')}</label>
|
||||
<select
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
CONTRACT_SECTIONS,
|
||||
} from '../../../services/contracts.service';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||
|
||||
interface BlockRow {
|
||||
blockId: number;
|
||||
@@ -63,6 +64,7 @@ export const ContractEditorPage: React.FC = () => {
|
||||
const [language, setLanguage] = useState('de');
|
||||
const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [validUntil, setValidUntil] = useState('');
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
const [blocks, setBlocks] = useState<BlockRow[]>([]);
|
||||
|
||||
// Load existing contract on edit.
|
||||
@@ -110,6 +112,7 @@ export const ContractEditorPage: React.FC = () => {
|
||||
setLanguage(c.language || 'de');
|
||||
setIssueDate(c.issueDate);
|
||||
setValidUntil(c.validUntil || '');
|
||||
setProjectId(c.projectId ?? null);
|
||||
setBlocks((c.inclusions || []).map((inc) => ({
|
||||
blockId: inc.blockId,
|
||||
section: inc.section,
|
||||
@@ -188,6 +191,7 @@ export const ContractEditorPage: React.FC = () => {
|
||||
outroText: outroText || null,
|
||||
issueDate,
|
||||
validUntil: validUntil || undefined,
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
// Apply block toggles + ordering as an update right after create.
|
||||
await contractsService.update(created.contract.id, {
|
||||
@@ -202,6 +206,10 @@ export const ContractEditorPage: React.FC = () => {
|
||||
navigate(`/admin/clients/contracts/${created.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
|
||||
toast.error(t('projects.error.customerMismatch', 'That project belongs to a different customer than this entry.') as string);
|
||||
return;
|
||||
}
|
||||
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
|
||||
},
|
||||
});
|
||||
@@ -220,6 +228,7 @@ export const ContractEditorPage: React.FC = () => {
|
||||
language,
|
||||
issueDate,
|
||||
validUntil: validUntil || undefined,
|
||||
projectId: projectId ?? null,
|
||||
blocks: blocks.map((b) => ({
|
||||
blockId: b.blockId, included: b.included, position: b.position,
|
||||
})),
|
||||
@@ -230,6 +239,10 @@ export const ContractEditorPage: React.FC = () => {
|
||||
navigate(`/admin/clients/contracts/${numericId}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
|
||||
toast.error(t('projects.error.customerMismatch', 'That project belongs to a different customer than this entry.') as string);
|
||||
return;
|
||||
}
|
||||
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
|
||||
},
|
||||
});
|
||||
@@ -348,6 +361,16 @@ export const ContractEditorPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project link (renders only when the projects feature is on). */}
|
||||
<div className="mb-4">
|
||||
<ProjectSelect
|
||||
label={t('projects.picker.label', 'Project') as string}
|
||||
value={projectId}
|
||||
customerAccountId={customerAccountId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Admin → Project Cockpit (the 360° Project Overview).
|
||||
*
|
||||
* One project, everything in it: an editable header, a milestone timeline,
|
||||
* and a single dated feed merging every email (with the actual sent HTML
|
||||
* preview + resend/cancel/retry/send-now actions), quote, contract, invoice,
|
||||
* gallery and logged hour that rolls up to the project. Admin-only.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
Mail, FileText, ScrollText, Receipt, Image as ImageIcon, Clock,
|
||||
X, Send, RotateCw, Ban, Eye, Save, ArrowLeft, Plus, Search,
|
||||
} from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
import {
|
||||
projectsService,
|
||||
type ProjectOverview,
|
||||
type EmailPreview,
|
||||
type ProjectMilestone,
|
||||
} from '../../../services/projects.service';
|
||||
import { eventsService } from '../../../services/events.service';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext';
|
||||
|
||||
type FeedKind = 'email' | 'quote' | 'contract' | 'invoice' | 'gallery' | 'hours';
|
||||
|
||||
interface FeedItem {
|
||||
key: string;
|
||||
kind: FeedKind;
|
||||
date: string | null;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
amount?: string;
|
||||
status?: string;
|
||||
href?: string | null;
|
||||
emailId?: number;
|
||||
emailStatus?: string;
|
||||
reRendered?: boolean;
|
||||
}
|
||||
|
||||
/** The feature flag that gates each document's detail ROUTE (RequireFeature
|
||||
* in App.tsx). The cockpit surfaces docs by permission, but their detail
|
||||
* pages live behind these flags — so a link is only live when the flag is
|
||||
* on, else clicking would bounce to /admin/dashboard. Galleries/events have
|
||||
* no such gate. */
|
||||
const FLAG_FOR_KIND: Partial<Record<FeedKind, FeatureKey>> = {
|
||||
quote: 'quotes',
|
||||
contract: 'contracts',
|
||||
invoice: 'bills',
|
||||
};
|
||||
|
||||
/** Detail-page route for a clickable document, or null when there isn't one
|
||||
* (hours have no page; emails open the preview) OR the destination's feature
|
||||
* flag is off (so we don't render a link that just redirects away). */
|
||||
function hrefFor(
|
||||
kind: FeedKind | ProjectMilestone['kind'],
|
||||
id: number | undefined,
|
||||
flags: Record<string, boolean>,
|
||||
): string | null {
|
||||
if (id == null) return null;
|
||||
const flag = FLAG_FOR_KIND[kind as FeedKind];
|
||||
if (flag && !flags[flag]) return null;
|
||||
switch (kind) {
|
||||
case 'quote': return `/admin/clients/quotes/${id}`;
|
||||
case 'contract': return `/admin/clients/contracts/${id}`;
|
||||
case 'invoice': return `/admin/clients/bills/${id}`;
|
||||
case 'gallery': return `/admin/events/${id}`;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<FeedKind, React.ComponentType<{ className?: string }>> = {
|
||||
email: Mail,
|
||||
quote: FileText,
|
||||
contract: ScrollText,
|
||||
invoice: Receipt,
|
||||
gallery: ImageIcon,
|
||||
hours: Clock,
|
||||
};
|
||||
|
||||
/** Prepare the rendered email for a read-only preview:
|
||||
* - `<base target="_blank">` so the sandboxed iframe (no allow-popups) blocks
|
||||
* every link, preventing accidental clicks on the live Accept/Decline URLs.
|
||||
* - `overflow-wrap:break-word` so a long unbreakable token (e.g. the gallery
|
||||
* link) wraps inside the container instead of forcing horizontal scroll.
|
||||
* break-word only kicks in on overflow, so it won't disturb table layout. */
|
||||
function preparePreviewHtml(html: string): string {
|
||||
const inject = '<base target="_blank"><style>*{overflow-wrap:break-word}</style>';
|
||||
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + inject);
|
||||
return inject + html;
|
||||
}
|
||||
|
||||
function minutesToHours(min: number): string {
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export const ProjectCockpitPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const projectId = id ? parseInt(id, 10) : null;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { flags } = useFeatureFlags();
|
||||
const { format, formatTime } = useLocalizedDate();
|
||||
|
||||
const [editName, setEditName] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<EmailPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [eventSearch, setEventSearch] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery<ProjectOverview>({
|
||||
queryKey: ['project-overview', projectId],
|
||||
queryFn: () => projectsService.overview(projectId as number),
|
||||
enabled: projectId !== null,
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (name: string) => projectsService.update(projectId as number, { name }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
|
||||
qc.invalidateQueries({ queryKey: ['projects'] });
|
||||
setEditName(null);
|
||||
toast.success(t('projects.toast.saved', 'Project saved') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.saveFailed', 'Save failed') as string)),
|
||||
});
|
||||
|
||||
const emailActionMutation = useMutation({
|
||||
mutationFn: ({ action, emailId }: { action: 'resend' | 'cancel' | 'retry' | 'sendNow'; emailId: number }) => {
|
||||
if (action === 'resend') return projectsService.resendEmail(emailId);
|
||||
if (action === 'cancel') return projectsService.cancelEmail(emailId);
|
||||
if (action === 'retry') return projectsService.retryEmail(emailId);
|
||||
return projectsService.sendEmailNow(emailId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
|
||||
toast.success(t('projects.toast.emailAction', 'Done') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.emailActionFailed', 'Action failed') as string)),
|
||||
});
|
||||
|
||||
// Event search for the "attach event" control (results exclude events
|
||||
// already on this project).
|
||||
const { data: eventResults } = useQuery({
|
||||
queryKey: ['project-event-search', eventSearch],
|
||||
queryFn: () => eventsService.getEvents(1, 10, undefined, eventSearch),
|
||||
enabled: eventSearch.trim().length >= 2,
|
||||
});
|
||||
|
||||
const attachEventMutation = useMutation({
|
||||
mutationFn: (eventId: number) => projectsService.assignEvent(projectId as number, eventId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
|
||||
qc.invalidateQueries({ queryKey: ['projects'] });
|
||||
setEventSearch('');
|
||||
toast.success(t('projects.events.attached', 'Event attached') as string);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
|
||||
toast.error(t('projects.error.customerMismatch', 'That belongs to a different customer than this project.') as string);
|
||||
return;
|
||||
}
|
||||
toast.error(err?.response?.data?.error || (t('projects.events.attachFailed', 'Could not attach event') as string));
|
||||
},
|
||||
});
|
||||
|
||||
const openPreview = async (emailId: number) => {
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const p = await projectsService.emailPreview(emailId);
|
||||
setPreview(p);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || (t('projects.toast.previewFailed', 'Could not load preview') as string));
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Merge every rolled-up document into one dated feed (newest first).
|
||||
const feed = useMemo<FeedItem[]>(() => {
|
||||
if (!data) return [];
|
||||
const items: FeedItem[] = [];
|
||||
for (const e of data.emails) {
|
||||
items.push({
|
||||
key: `email-${e.id}`, kind: 'email', date: e.sentAt || e.queuedAt,
|
||||
title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`,
|
||||
subtitle: e.recipient + (e.error ? ` — ${e.error}` : ''),
|
||||
status: e.status, emailId: e.id, emailStatus: e.status, reRendered: !e.stored,
|
||||
});
|
||||
}
|
||||
for (const q of data.quotes) {
|
||||
items.push({
|
||||
key: `quote-${q.id}`, kind: 'quote', date: q.issue_date,
|
||||
title: t('projects.feed.quote', 'Quote') + ` ${q.quote_number}`,
|
||||
status: q.status, amount: formatMoneyMinor(Number(q.total_amount_minor), q.currency),
|
||||
href: hrefFor('quote', q.id, flags),
|
||||
});
|
||||
}
|
||||
for (const c of data.contracts) {
|
||||
items.push({
|
||||
key: `contract-${c.id}`, kind: 'contract', date: c.issue_date,
|
||||
title: t('projects.feed.contract', 'Contract') + ` ${c.contract_number}`,
|
||||
status: c.status, href: hrefFor('contract', c.id, flags),
|
||||
});
|
||||
}
|
||||
for (const inv of data.invoices) {
|
||||
items.push({
|
||||
key: `invoice-${inv.id}`, kind: 'invoice', date: inv.issue_date,
|
||||
title: t('projects.feed.invoice', 'Invoice') + ` ${inv.invoice_number}`,
|
||||
status: inv.status, amount: formatMoneyMinor(Number(inv.total_amount_minor), inv.currency),
|
||||
href: hrefFor('invoice', inv.id, flags),
|
||||
});
|
||||
}
|
||||
for (const ev of data.events) {
|
||||
items.push({
|
||||
key: `gallery-${ev.id}`, kind: 'gallery', date: ev.event_date,
|
||||
title: t('projects.feed.gallery', 'Gallery') + ` · ${ev.event_name}`,
|
||||
subtitle: ev.slug, href: hrefFor('gallery', ev.id, flags),
|
||||
});
|
||||
}
|
||||
for (const h of data.hours.entries) {
|
||||
items.push({
|
||||
key: `hours-${h.id}`, kind: 'hours', date: h.entry_date,
|
||||
title: t('projects.feed.hours', 'Hours') + ` · ${minutesToHours(h.duration_minutes)}`,
|
||||
subtitle: h.description || undefined, status: h.status || undefined,
|
||||
});
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
const da = a.date ? new Date(a.date).getTime() : 0;
|
||||
const db = b.date ? new Date(b.date).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
}, [data, t, flags]);
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (!data) return <div className="p-6 text-neutral-500">{t('projects.notFound', 'Project not found')}</div>;
|
||||
|
||||
const { project, milestones, hours, valuation } = data;
|
||||
const valueBuckets = valuation?.byCurrency?.filter((b) => b.totalMinor !== 0 || b.paidMinor !== 0) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to="/admin/clients/projects" className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 mb-3">
|
||||
<ArrowLeft className="w-4 h-4" />{t('projects.backToList', 'All projects')}
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<Card className="mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
{editName === null ? (
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{project.name}</h1>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} className="max-w-sm" />
|
||||
<Button variant="primary" disabled={!editName.trim() || renameMutation.isPending} onClick={() => renameMutation.mutate(editName.trim())}>
|
||||
<Save className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditName(null)}><X className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{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) })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
{valueBuckets.length > 0 && (
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">{t('projects.value.label', 'Project value')}</div>
|
||||
{valueBuckets.map((b) => (
|
||||
<div key={b.currency} className="text-lg font-bold text-neutral-900 dark:text-neutral-100 tabular-nums">
|
||||
{formatMoneyMinor(b.totalMinor, b.currency)}
|
||||
</div>
|
||||
))}
|
||||
{valueBuckets.some((b) => b.paidMinor !== 0) && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('projects.value.paid', 'paid')}: {valueBuckets.map((b) => formatMoneyMinor(b.paidMinor, b.currency)).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{editName === null && (
|
||||
<Button variant="outline" onClick={() => setEditName(project.name)}>{t('projects.rename', 'Rename')}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events in this project + attach control */}
|
||||
<Card className="mb-4">
|
||||
<h2 className="text-sm font-semibold mb-3 text-neutral-700 dark:text-neutral-300">{t('projects.events.title', 'Events')}</h2>
|
||||
{data.events.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 mb-3">{t('projects.events.none', 'No events grouped under this project yet.')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1 mb-3">
|
||||
{data.events.map((ev) => (
|
||||
<li key={ev.id} className="flex items-center justify-between text-sm rounded-md border border-neutral-100 dark:border-neutral-800 px-3 py-1.5">
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">{ev.event_name}</span>
|
||||
<span className="text-xs text-neutral-500">{ev.event_date ? format(ev.event_date) : '—'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<Input
|
||||
value={eventSearch}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-md border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-lg max-h-56 overflow-auto">
|
||||
{eventResults.events
|
||||
.filter((ev: any) => !data.events.some((existing) => existing.id === ev.id))
|
||||
.map((ev: any) => (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => attachEventMutation.mutate(ev.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<Plus className="w-3 h-3 text-neutral-400" />
|
||||
<span className="flex-1 truncate text-neutral-900 dark:text-neutral-100">{ev.event_name}</span>
|
||||
<span className="text-xs text-neutral-500">{ev.event_date ? format(ev.event_date) : ''}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Milestone timeline */}
|
||||
{milestones && milestones.length > 0 && (
|
||||
<Card className="mb-4">
|
||||
<h2 className="text-sm font-semibold mb-3 text-neutral-700 dark:text-neutral-300">{t('projects.timeline', 'Milestones')}</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{milestones.map((m, i) => {
|
||||
const Icon = KIND_ICON[m.kind] || FileText;
|
||||
const href = hrefFor(m.kind, m.id, flags);
|
||||
return (
|
||||
<div
|
||||
key={`${m.kind}-${i}`}
|
||||
onClick={href ? () => navigate(href) : undefined}
|
||||
className={`flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2 ${href ? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/60' : ''}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-neutral-500" />
|
||||
<div>
|
||||
<div className="text-xs font-medium text-neutral-900 dark:text-neutral-100">{m.label}</div>
|
||||
<div className="text-xs text-neutral-500">{m.date ? format(m.date) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dated feed */}
|
||||
<Card>
|
||||
<h2 className="text-sm font-semibold mb-3 text-neutral-700 dark:text-neutral-300">{t('projects.feed.title', 'Activity')}</h2>
|
||||
{feed.length === 0 ? (
|
||||
<div className="text-center py-8 text-neutral-500">{t('projects.feed.empty', 'Nothing rolled up to this project yet.')}</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{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 (
|
||||
<li
|
||||
key={item.key}
|
||||
onClick={onRowClick}
|
||||
className={`flex items-start gap-3 rounded-lg border border-neutral-100 dark:border-neutral-800 px-3 py-2 ${onRowClick ? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/60' : ''}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mt-0.5 text-neutral-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{item.title}</span>
|
||||
<span className="text-xs text-neutral-500 flex-shrink-0">
|
||||
{item.date ? `${format(item.date)} ${item.kind === 'email' ? formatTime(item.date) : ''}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{item.subtitle && <div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{item.subtitle}</div>}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{item.status && (
|
||||
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300">{item.status}</span>
|
||||
)}
|
||||
{item.amount && <span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">{item.amount}</span>}
|
||||
{item.kind === 'email' && item.emailId != null && (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
|
||||
<Eye className="w-3 h-3" />{t('projects.email.preview', 'Preview')}
|
||||
</button>
|
||||
{item.reRendered && (
|
||||
<span
|
||||
title={t('projects.email.reRendered', 'Re-rendered from the current template — may differ slightly from what was sent.') as string}
|
||||
className="inline-block rounded-full px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{t('projects.email.reRenderedTag', '≈ re-rendered')}
|
||||
</span>
|
||||
)}
|
||||
{item.emailStatus === 'sent' && (
|
||||
<button onClick={() => emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
|
||||
<Send className="w-3 h-3" />{t('projects.email.resend', 'Resend')}
|
||||
</button>
|
||||
)}
|
||||
{item.emailStatus === 'pending' && (
|
||||
<>
|
||||
<button onClick={() => emailActionMutation.mutate({ action: 'sendNow', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
|
||||
<Send className="w-3 h-3" />{t('projects.email.sendNow', 'Send now')}
|
||||
</button>
|
||||
<button onClick={() => emailActionMutation.mutate({ action: 'cancel', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-red-600 hover:underline">
|
||||
<Ban className="w-3 h-3" />{t('projects.email.cancel', 'Cancel')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{item.emailStatus === 'failed' && (
|
||||
<button onClick={() => emailActionMutation.mutate({ action: 'retry', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-amber-600 hover:underline">
|
||||
<RotateCw className="w-3 h-3" />{t('projects.email.retry', 'Retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Email preview modal */}
|
||||
{(preview || previewLoading) && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setPreview(null)}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-4 py-3">
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">{t('projects.email.previewTitle', 'Email preview')}</h3>
|
||||
<button onClick={() => setPreview(null)} className="text-neutral-500 hover:text-neutral-700"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{previewLoading ? (
|
||||
<Loading />
|
||||
) : preview && preview.available && preview.html ? (
|
||||
<>
|
||||
{!preview.exact && (
|
||||
<div className="mb-3 rounded-md bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
{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.')}
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<iframe
|
||||
title="email-preview"
|
||||
srcDoc={preparePreviewHtml(preview.html)}
|
||||
sandbox=""
|
||||
style={{ colorScheme: 'normal' }}
|
||||
className="w-full h-[60vh] border border-neutral-200 dark:border-neutral-700 rounded"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-10 text-neutral-500">
|
||||
{t('projects.email.noPreview', 'No stored preview for this email — it was sent before previews were captured.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Admin → Project Overview list page.
|
||||
*
|
||||
* Lists every project (the admin-only grouping layer above events) with a
|
||||
* search box, an inline "new project" creator, and a click-through to each
|
||||
* project's cockpit. Visual shape mirrors the other /admin/clients lists so
|
||||
* the CRM area feels like one product. Admin-only — customers never see it.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Plus, Search, FolderKanban } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
import { projectsService, type ProjectSummary } from '../../../services/projects.service';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
|
||||
/** Render a project's rolled-up value (newest stage per deal, cumulative),
|
||||
* one entry per currency. Convention (deliberately differs from the Events
|
||||
* column): a zero *count* is a real number → "0"; a zero *value* means "nothing
|
||||
* billed/quoted yet" → em dash, since "CHF 0.00" would wrongly imply a real
|
||||
* zero-value deal. */
|
||||
function formatValuation(p: ProjectSummary): string {
|
||||
const buckets = p.valuation?.byCurrency?.filter((b) => b.totalMinor !== 0) || [];
|
||||
if (buckets.length === 0) return '—';
|
||||
return buckets.map((b) => formatMoneyMinor(b.totalMinor, b.currency)).join(' · ');
|
||||
}
|
||||
|
||||
export const ProjectsListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { format } = useLocalizedDate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const { data: projects, isLoading } = useQuery({
|
||||
queryKey: ['projects', { search }],
|
||||
queryFn: () => projectsService.list({ q: search || undefined }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => projectsService.create({ name: newName.trim() }),
|
||||
onSuccess: (project) => {
|
||||
qc.invalidateQueries({ queryKey: ['projects'] });
|
||||
setNewName('');
|
||||
toast.success(t('projects.toast.created', 'Project created') as string);
|
||||
navigate(`/admin/clients/projects/${project.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error || err?.message || (t('projects.toast.createFailed', 'Could not create project') as string));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<FolderKanban className="w-6 h-6 text-neutral-500" />
|
||||
{t('projects.title', 'Project Overview')}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('projects.subtitle', 'Group events into projects and see every email, document, gallery and hour in one cockpit.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline create */}
|
||||
<Card className="mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('projects.create.label', 'New project name')}
|
||||
</label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && newName.trim()) createMutation.mutate(); }}
|
||||
placeholder={t('projects.create.placeholder', 'e.g. Müller wedding 2026') as string}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!newName.trim() || createMutation.isPending}
|
||||
isLoading={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />{t('projects.create.button', 'Create project')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-3 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('projects.search', 'Search by name or customer…') as string}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : !projects || projects.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-10 text-neutral-500 dark:text-neutral-400">
|
||||
{t('projects.empty', 'No projects yet. Create one above, or events you already have were grouped automatically.')}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('projects.col.name', 'Project')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('projects.col.customer', 'Customer')}</th>
|
||||
<th className="px-4 py-2 font-medium text-right">{t('projects.col.events', 'Events')}</th>
|
||||
<th className="px-4 py-2 font-medium text-right">{t('projects.col.value', 'Value')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('projects.col.status', 'Status')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('projects.col.updated', 'Updated')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((p: ProjectSummary) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
onClick={() => navigate(`/admin/clients/projects/${p.id}`)}
|
||||
className="border-t border-neutral-100 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/60 cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-2 font-medium text-neutral-900 dark:text-neutral-100">{p.name}</td>
|
||||
<td className="px-4 py-2 text-neutral-600 dark:text-neutral-400">{p.customerEmail || '—'}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{p.eventCount ?? 0}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{formatValuation(p)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200">
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-neutral-500 dark:text-neutral-400">{p.updatedAt ? format(p.updatedAt) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../../services/quotes.service';
|
||||
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { userManagementService } from '../../../services/userManagement.service';
|
||||
@@ -59,6 +60,8 @@ interface FormState {
|
||||
internalNotes: string;
|
||||
ccPdfEmail: string;
|
||||
businessBankAccountId: number | null;
|
||||
/** Migration 121 — optional Project Overview link. */
|
||||
projectId: number | null;
|
||||
lineItems: EditableLineItem[];
|
||||
// Ad-hoc installments (commit #6). null = use the payment-timing
|
||||
// template's installments; array = explicit per-quote override.
|
||||
@@ -88,6 +91,7 @@ const empty: FormState = {
|
||||
internalNotes: '',
|
||||
ccPdfEmail: '',
|
||||
businessBankAccountId: null,
|
||||
projectId: null,
|
||||
lineItems: [],
|
||||
installments: null,
|
||||
};
|
||||
@@ -123,6 +127,8 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
||||
internalNotes: f.internalNotes || undefined,
|
||||
ccPdfEmail: f.ccPdfEmail || undefined,
|
||||
businessBankAccountId: f.businessBankAccountId || undefined,
|
||||
// Migration 121 — Project Overview link. Send null to clear.
|
||||
projectId: f.projectId ?? null,
|
||||
lineItems: f.lineItems.map((li) => ({
|
||||
position: li.position,
|
||||
quantity: li.quantity,
|
||||
@@ -214,6 +220,7 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
internalNotes: q.internalNotes || '',
|
||||
ccPdfEmail: q.ccPdfEmail || '',
|
||||
businessBankAccountId: q.businessBankAccountId,
|
||||
projectId: q.projectId ?? null,
|
||||
lineItems: existing.lineItems.map((li) => ({
|
||||
id: li.id,
|
||||
position: li.position,
|
||||
@@ -389,6 +396,9 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
if (err?.response?.data?.code === 'CUSTOMER_FEATURE_DISABLED') {
|
||||
toast.error(t('quotes.errors.customerFeatureDisabled',
|
||||
'This customer has Quotes disabled. Enable "Quotes" on the customer detail page first.'));
|
||||
} else if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
|
||||
toast.error(t('projects.error.customerMismatch',
|
||||
'That project belongs to a different customer than this entry.'));
|
||||
} else if (err?.response?.data?.code === 'VALIDATION_ERROR' && Array.isArray(err?.response?.data?.details)) {
|
||||
// Show the first field that failed validation so the admin
|
||||
// knows what to fix instead of just seeing "Validation failed".
|
||||
@@ -479,6 +489,15 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
}))}
|
||||
searchPlaceholder={t('quotes.customerSearch', 'Search customer by email or company…') as string}
|
||||
/>
|
||||
{/* Project link (renders only when the projects feature is on). */}
|
||||
<div className="mt-3">
|
||||
<ProjectSelect
|
||||
label={t('projects.picker.label', 'Project') as string}
|
||||
value={form.projectId}
|
||||
customerAccountId={form.customerAccountId}
|
||||
onChange={(projectId) => setForm((f) => ({ ...f, projectId }))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Section: Event */}
|
||||
|
||||
@@ -21,6 +21,18 @@ import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../..
|
||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022
|
||||
// (all current browsers); fall back to a small CH/LI-relevant set on the rare
|
||||
// engine that lacks it.
|
||||
const IANA_TIMEZONES: string[] = (() => {
|
||||
try {
|
||||
// @ts-expect-error supportedValuesOf is ES2022, not yet in all TS lib defs
|
||||
return Intl.supportedValuesOf('timeZone') as string[];
|
||||
} catch {
|
||||
return ['UTC', 'Europe/Vaduz', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Paris', 'Europe/London'];
|
||||
}
|
||||
})();
|
||||
|
||||
export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -117,17 +129,26 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
|
||||
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
|
||||
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
|
||||
{/* Migration 137 — IANA timezone string for the admin calendar.
|
||||
Free-text; backend caps at 64 chars. When blank the calendar
|
||||
UI falls back to the browser's `Intl.DateTimeFormat()
|
||||
.resolvedOptions().timeZone`. */}
|
||||
<Input
|
||||
label={t('businessProfile.field.timezone', 'Timezone (IANA)') as string}
|
||||
value={profile.timezone || ''}
|
||||
maxLength={64}
|
||||
placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
|
||||
/>
|
||||
{/* Migration 137 — IANA timezone for the admin calendar + the
|
||||
scheduled-email business-hours snapping. Dropdown of the full
|
||||
IANA list; blank = fall back to the server/browser tz. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('businessProfile.field.timezone', 'Timezone (IANA)')}
|
||||
</label>
|
||||
<select
|
||||
value={profile.timezone || ''}
|
||||
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">
|
||||
{t('businessProfile.field.timezoneSystemDefault', 'System default')} ({Intl.DateTimeFormat().resolvedOptions().timeZone})
|
||||
</option>
|
||||
{IANA_TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
|
||||
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
|
||||
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Calendar, Clock, Download, ExternalLink, ImageIcon, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button, Loading } from '../../components/common';
|
||||
@@ -51,6 +51,10 @@ const DEFAULT_SORT: SortKey = 'newest';
|
||||
export const CustomerDashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// Localized date formatting — respects general_date_format + the active UI
|
||||
// language. Previously used raw date-fns `format(parseISO(iso),'PP')` with
|
||||
// no locale, so dates rendered en-US ("May", "Jun") under a German UI.
|
||||
const { format: fmtLocalized } = useLocalizedDate();
|
||||
|
||||
const { data: events, isLoading, error } = useQuery({
|
||||
queryKey: ['customer-events'],
|
||||
@@ -125,7 +129,7 @@ export const CustomerDashboardPage: React.FC = () => {
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return null;
|
||||
try { return format(parseISO(iso), 'PP'); } catch { return null; }
|
||||
try { return fmtLocalized(iso); } catch { return null; }
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface ContractSummary {
|
||||
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
|
||||
dealUuid: string | null;
|
||||
customerAccountId: number;
|
||||
/** Migration 121 — Project Overview link (null when unlinked). */
|
||||
projectId: number | null;
|
||||
customer: {
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
@@ -194,6 +196,8 @@ export interface ContractCreatePayload {
|
||||
outroText?: string | null;
|
||||
issueDate?: string;
|
||||
validUntil?: string;
|
||||
/** Migration 121 — optional link to a Project Overview project. */
|
||||
projectId?: number | null;
|
||||
}
|
||||
|
||||
export interface ContractUpdatePayload {
|
||||
@@ -211,6 +215,8 @@ export interface ContractUpdatePayload {
|
||||
* rows from this payload — caller controls inclusion + per-section
|
||||
* order via the position field. Omit to leave inclusions untouched. */
|
||||
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
|
||||
/** Migration 121 — optional Project Overview link. null clears it. */
|
||||
projectId?: number | null;
|
||||
}
|
||||
|
||||
export interface ContractBlockCreatePayload {
|
||||
|
||||
@@ -451,6 +451,8 @@ export interface HourEntryCreatePayload {
|
||||
endTime: string; // HH:MM
|
||||
hourlyRateMinorOverride?: number | null;
|
||||
description?: string | null;
|
||||
/** Migration 118 — optional "book to project" link. */
|
||||
projectId?: number | null;
|
||||
}
|
||||
|
||||
export interface HourEntryUpdatePayload {
|
||||
|
||||
@@ -54,7 +54,11 @@ export type FeatureKey =
|
||||
| 'incomingInvoices'
|
||||
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
|
||||
// Separate Accounting sub-feature; requires `accounting`.
|
||||
| 'expenses';
|
||||
| 'expenses'
|
||||
// Projects (migration 120). Admin-only grouping layer above events with the
|
||||
// 360° Project Overview cockpit + the "book to project" hours control. Off
|
||||
// by default; gates the CRM → Overview area entirely.
|
||||
| 'projects';
|
||||
|
||||
export type FeatureFlags = Record<FeatureKey, boolean>;
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Admin → Projects API client. Hits /api/admin/projects/*.
|
||||
*
|
||||
* Projects are the admin-only grouping layer above events (Model A). The
|
||||
* cockpit "overview" rolls up the per-event/per-customer documents. Mirrors
|
||||
* the contracts/bills service shape: `data.data || data` unwrap.
|
||||
*/
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type ProjectStatus = 'active' | 'archived' | string;
|
||||
|
||||
/** Rolled-up project value: newest stage wins per deal (invoice > quote;
|
||||
* contracts carry no total), cumulative across events, split by currency. */
|
||||
export interface ProjectValuation {
|
||||
byCurrency: Array<{ currency: string; totalMinor: number; paidMinor: number }>;
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
customerAccountId: number | null;
|
||||
customerEmail: string | null;
|
||||
status: ProjectStatus;
|
||||
eventCount?: number;
|
||||
valuation?: ProjectValuation;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectEvent {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_date: string | null;
|
||||
slug: string;
|
||||
is_active: boolean | number;
|
||||
is_draft: boolean | number;
|
||||
expires_at: string | null;
|
||||
is_archived: boolean | number;
|
||||
}
|
||||
|
||||
export interface ProjectEmail {
|
||||
id: number;
|
||||
recipient: string;
|
||||
type: string;
|
||||
status: string;
|
||||
queuedAt: string | null;
|
||||
sentAt: string | null;
|
||||
error: string | null;
|
||||
eventId: number | null;
|
||||
/** true = exact HTML stored at send time; false = preview re-rendered. */
|
||||
stored: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectInvoice {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
status: string;
|
||||
kind: string | null;
|
||||
issue_date: string | null;
|
||||
due_date: string | null;
|
||||
total_amount_minor: number;
|
||||
paid_amount_minor: number | null;
|
||||
paid_at: string | null;
|
||||
currency: string;
|
||||
event_id: number | null;
|
||||
deal_uuid: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectQuote {
|
||||
id: number;
|
||||
quote_number: string;
|
||||
status: string;
|
||||
issue_date: string | null;
|
||||
valid_until: string | null;
|
||||
total_amount_minor: number;
|
||||
currency: string;
|
||||
deal_uuid: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectContract {
|
||||
id: number;
|
||||
contract_number: string;
|
||||
status: string;
|
||||
issue_date: string | null;
|
||||
signed_by_customer_at: string | null;
|
||||
deal_uuid: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectHourEntry {
|
||||
id: number;
|
||||
entry_date: string | null;
|
||||
duration_minutes: number;
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
invoice_id: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectMilestone {
|
||||
kind: 'quote' | 'contract' | 'gallery' | 'invoice';
|
||||
id?: number;
|
||||
label: string;
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectOverview {
|
||||
project: ProjectSummary;
|
||||
events: ProjectEvent[];
|
||||
emails: ProjectEmail[];
|
||||
quotes: ProjectQuote[];
|
||||
contracts: ProjectContract[];
|
||||
invoices: ProjectInvoice[];
|
||||
hours: { entries: ProjectHourEntry[]; totalMinutes: number };
|
||||
milestones: ProjectMilestone[];
|
||||
valuation: ProjectValuation;
|
||||
}
|
||||
|
||||
export interface EmailPreview {
|
||||
id: number;
|
||||
recipient: string;
|
||||
type: string;
|
||||
status: string;
|
||||
available: boolean;
|
||||
/** true = exact bytes stored at send time; false = re-rendered from the
|
||||
* current template (approximation for emails sent before capture). */
|
||||
exact: boolean;
|
||||
html: string | null;
|
||||
}
|
||||
|
||||
export const projectsService = {
|
||||
async list(params: { q?: string; status?: string } = {}): Promise<ProjectSummary[]> {
|
||||
const { data } = await api.get('/admin/projects', { params });
|
||||
const body = data.data || data;
|
||||
return body.projects || [];
|
||||
},
|
||||
|
||||
async get(id: number): Promise<ProjectSummary> {
|
||||
const { data } = await api.get(`/admin/projects/${id}`);
|
||||
const body = data.data || data;
|
||||
return body.project;
|
||||
},
|
||||
|
||||
async create(payload: { name: string; customerAccountId?: number | null }): Promise<ProjectSummary> {
|
||||
const { data } = await api.post('/admin/projects', payload);
|
||||
const body = data.data || data;
|
||||
return body.project;
|
||||
},
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
payload: { name?: string; customerAccountId?: number | null; status?: string },
|
||||
): Promise<ProjectSummary> {
|
||||
const { data } = await api.put(`/admin/projects/${id}`, payload);
|
||||
const body = data.data || data;
|
||||
return body.project;
|
||||
},
|
||||
|
||||
async overview(id: number): Promise<ProjectOverview> {
|
||||
const { data } = await api.get(`/admin/projects/${id}/overview`);
|
||||
return (data.data || data) as ProjectOverview;
|
||||
},
|
||||
|
||||
async assignEvent(projectId: number, eventId: number): Promise<void> {
|
||||
await api.post(`/admin/projects/${projectId}/events`, { eventId });
|
||||
},
|
||||
|
||||
async assignQuote(projectId: number, quoteId: number): Promise<void> {
|
||||
await api.post(`/admin/projects/${projectId}/quotes`, { quoteId });
|
||||
},
|
||||
|
||||
async assignContract(projectId: number, contractId: number): Promise<void> {
|
||||
await api.post(`/admin/projects/${projectId}/contracts`, { contractId });
|
||||
},
|
||||
|
||||
async emailPreview(emailId: number): Promise<EmailPreview> {
|
||||
const { data } = await api.get(`/admin/projects/email/${emailId}/preview`);
|
||||
return (data.data || data) as EmailPreview;
|
||||
},
|
||||
|
||||
async resendEmail(emailId: number): Promise<void> {
|
||||
await api.post(`/admin/projects/email/${emailId}/resend`);
|
||||
},
|
||||
|
||||
async cancelEmail(emailId: number): Promise<void> {
|
||||
await api.post(`/admin/projects/email/${emailId}/cancel`);
|
||||
},
|
||||
|
||||
async retryEmail(emailId: number): Promise<void> {
|
||||
await api.post(`/admin/projects/email/${emailId}/retry`);
|
||||
},
|
||||
|
||||
async sendEmailNow(emailId: number): Promise<void> {
|
||||
await api.post(`/admin/projects/email/${emailId}/send-now`);
|
||||
},
|
||||
};
|
||||
@@ -43,6 +43,8 @@ export interface QuoteSummary {
|
||||
* doc — contract, invoices, Storni — that shares this deal. */
|
||||
dealUuid: string | null;
|
||||
customerAccountId: number;
|
||||
/** Migration 121 — Project Overview link (null when unlinked). */
|
||||
projectId: number | null;
|
||||
customer: {
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
@@ -195,6 +197,9 @@ export interface QuoteCreatePayload {
|
||||
internalNotes?: string;
|
||||
ccPdfEmail?: string;
|
||||
businessBankAccountId?: number;
|
||||
/** Migration 121 — optional link to a Project Overview project.
|
||||
* null clears the link; undefined leaves it unchanged. */
|
||||
projectId?: number | null;
|
||||
lineItems: QuoteLineItem[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user