diff --git a/backend/migrations/core/120_seed_projects_feature_flag.js b/backend/migrations/core/120_seed_projects_feature_flag.js new file mode 100644 index 00000000..2673ddd9 --- /dev/null +++ b/backend/migrations/core/120_seed_projects_feature_flag.js @@ -0,0 +1,24 @@ +/** + * Migration: seed the `projects` feature flag (default OFF). + * + * Gates the admin-only Project Overview cockpit + the "book to project" hours + * control. Off by default so existing installs don't suddenly surface a new + * top-level CRM area — the admin opts in under Settings → Features, exactly + * like bills/quotes/contracts/hours. + * + * Idempotent: inserts only when the row is missing (migration 088 already + * seeded the original flag set on fresh installs and won't re-run). + */ + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + const existing = await knex('feature_flags').where({ key: 'projects' }).first(); + if (!existing) { + await knex('feature_flags').insert({ key: 'projects', value: false }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + await knex('feature_flags').where({ key: 'projects' }).del(); +}; diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index f76b2ceb..04575e1d 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -62,6 +62,10 @@ const KNOWN_FLAGS = [ // upload). Seeded block bodies are EXAMPLES ONLY; admins must have a // lawyer review before sending. See docs/crm-disclaimers.md. 'contracts', + // Projects (migration 120). Admin-only grouping layer above events + + // the Project Overview cockpit ("book to project" hours control, 360° + // rollup feed). Lights up the Clients section. Customers never see it. + 'projects', ]; // Spec defaults for any flag missing from the DB (e.g. a row added by a @@ -84,6 +88,7 @@ const DEFAULT_FLAGS = { taxReport: false, hoursLogging: false, contracts: false, + projects: false, }; async function readAllFlags() { @@ -123,6 +128,8 @@ function applyDependencyRules(flags) { || out.taxReport || out.hoursLogging || out.contracts + // Migration 120 — admin-only Project Overview cockpit lives under Clients. + || out.projects // Migration 137 — admin calendar lights up the Clients section. // (calendarBooking is gated behind `calendar` so adding the parent // is sufficient.) diff --git a/backend/src/routes/adminProjects.js b/backend/src/routes/adminProjects.js index ec19d326..180566b4 100644 --- a/backend/src/routes/adminProjects.js +++ b/backend/src/routes/adminProjects.js @@ -14,10 +14,23 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission, userHasAnyPermission } = require('../middleware/permissions'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const projectService = require('../services/projectService'); +const { db } = require('../database/db'); const router = express.Router(); router.use(adminAuth); +// Projects is feature-flagged like bills/quotes — when off, the whole cockpit +// (and the "book to project" hours control) is hidden, and the API 403s. +async function requireProjectsFlag(req, res, next) { + try { + const row = await db('feature_flags').where({ key: 'projects' }).first(); + const enabled = row && (row.value === true || row.value === 1 || row.value === '1'); + if (!enabled) return res.status(403).json({ error: 'Projects feature is disabled', code: 'PROJECTS_DISABLED' }); + next(); + } catch (err) { next(err); } +} +router.use(requireProjectsFlag); + // List router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => { const projects = await projectService.listProjects({ @@ -99,4 +112,16 @@ router.get('/email/:emailId/preview', requirePermission('events.view'), [param(' return successResponse(res, preview); })); +// Email actions from the feed (need email.send). Resend (sent→fresh copy), +// Cancel (pending→cancelled), Retry (failed→pending), Send now (flush this one). +const emailAction = (fn) => handleAsync(async (req, res) => { + validateRequest(req); + const result = await projectService[fn](parseInt(req.params.emailId, 10)); + return successResponse(res, result); +}); +router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('resendEmail')); +router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('cancelEmail')); +router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('retryEmail')); +router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('sendEmailNow')); + module.exports = router; diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js index 54db20d7..9755e22b 100644 --- a/backend/src/services/projectService.js +++ b/backend/src/services/projectService.js @@ -196,6 +196,48 @@ async function getEmailPreview(emailId) { }; } +// ── Email actions (from the cockpit feed) ─────────────────────────────── + +async function resendEmail(emailId) { + const row = await db('email_queue').where({ id: emailId }).first(); + if (!row) throw new AppError('Email not found', 404); + const insert = await db('email_queue').insert({ + recipient_email: row.recipient_email, + email_type: row.email_type, + email_data: row.email_data, + event_id: row.event_id, + status: 'pending', + retry_count: 0, + created_at: new Date(), + }).returning('id'); + const id = (insert[0] && typeof insert[0] === 'object') ? insert[0].id : insert[0]; + return { id, status: 'pending' }; +} + +async function cancelEmail(emailId) { + const row = await db('email_queue').where({ id: emailId }).first(); + if (!row) throw new AppError('Email not found', 404); + if (row.status !== 'pending') throw new AppError('Only pending emails can be cancelled', 409); + await db('email_queue').where({ id: emailId }).update({ status: 'cancelled' }); + return { id: emailId, status: 'cancelled' }; +} + +async function retryEmail(emailId) { + const row = await db('email_queue').where({ id: emailId }).first(); + if (!row) throw new AppError('Email not found', 404); + await db('email_queue').where({ id: emailId }) + .update({ status: 'pending', retry_count: 0, error_message: null, scheduled_at: null }); + return { id: emailId, status: 'pending' }; +} + +async function sendEmailNow(emailId) { + const row = await db('email_queue').where({ id: emailId }).first(); + if (!row) throw new AppError('Email not found', 404); + await db('email_queue').where({ id: emailId }).update({ status: 'pending', scheduled_at: null }); + const { processEmailQueue } = require('./emailProcessor'); + return processEmailQueue({ ignoreSchedule: true, limit: 50 }); +} + module.exports = { listProjects, getProjectById, @@ -204,4 +246,8 @@ module.exports = { assignEvent, getProjectOverview, getEmailPreview, + resendEmail, + cancelEmail, + retryEmail, + sendEmailNow, }; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index b0da55dc..36b56fd7 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -45,6 +45,10 @@ export const DEFAULT_FLAGS: FeatureFlags = { // Settings → Features once they've reviewed the seeded block // library with their lawyer. contracts: false, + // Projects (migration 120). Admin-only grouping layer above events + + // the Project Overview cockpit. Off by default — admin opts in under + // Settings → Features once they want the CRM → Overview area. + projects: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index b5cefdc7..0d4fd6bb 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -16,6 +16,7 @@ import { Briefcase, Wrench, Calculator, + FolderKanban, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -291,6 +292,20 @@ export const FeaturesTab: React.FC = () => { enabled={staged.hoursLogging} onToggle={(next) => setFlag('hoursLogging', next)} /> + + setFlag('projects', next)} + /> {/* Insights & Access */} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index a33d2dbd..c955df08 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1666,6 +1666,11 @@ "title": "Stundenerfassung", "description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.", "sidebar": "Stunden" + }, + "projects": { + "title": "Projekte", + "description": "Nur-Admin-Gruppierungsebene über Events. Bündle mehrere Events unter einem Projekt und öffne ein 360°-Projektübersichts-Cockpit — Meilenstein-Zeitleiste plus ein datierter Verlauf aller E-Mails (mit der tatsächlich gesendeten Vorschau + Erneut-senden/Abbrechen/Wiederholen-Aktionen), Angebote, Verträge, Rechnungen, Galerien und erfassten Stunden. Fügt beim Erfassen von Stunden eine „Auf Projekt buchen“-Option hinzu. Kunden sehen Projekte nie.", + "sidebar": "Übersicht" } }, "customerSurface": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 757c2d55..7e8c5a03 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1224,6 +1224,11 @@ "title": "Hours logging", "description": "Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a \"Create draft invoice\" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.", "sidebar": "Hours" + }, + "projects": { + "title": "Projects", + "description": "Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a \"book to project\" control when logging hours. Customers never see projects.", + "sidebar": "Overview" } }, "customerSurface": { diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts index c5015e8f..ad3c95e1 100644 --- a/frontend/src/services/featureFlags.service.ts +++ b/frontend/src/services/featureFlags.service.ts @@ -41,7 +41,11 @@ export type FeatureKey = // upload. Independent of quotes / bills — contracts can be sent on // their own. Seeded block bodies are examples only; admins must // have their lawyer review before sending. See docs/crm-disclaimers.md. - | 'contracts'; + | 'contracts' + // Projects (migration 120). Admin-only grouping layer above events with the + // 360° Project Overview cockpit + the "book to project" hours control. Off + // by default; gates the CRM → Overview area entirely. + | 'projects'; export type FeatureFlags = Record;