feat(projects): gated project pickers on quote/contract/hours editors

- ProjectSelect: a reusable picker that renders nothing when the projects
  flag is off (satisfies 'book to project hidden unless projects enabled').
- projects.service.ts: full frontend API client (list/get/create/update,
  overview, assign event/quote/contract, email preview + 4 actions).
- Quote + contract editors carry an optional projectId (state, prefill,
  payload); service payload/detail types updated.
- HoursSection gains a 'book to project' control; backend createEntry
  persists project_id (migration 118, hasColumnCached guarded).
This commit is contained in:
Luca
2026-06-06 13:20:59 +02:00
parent 6420047e7c
commit 0175007abc
10 changed files with 315 additions and 0 deletions
+1
View File
@@ -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(
@@ -233,6 +233,10 @@ async function createEntry(customerId, payload, adminId) {
created_at: new Date(),
updated_at: new Date(),
};
// Migration 118 — optional "book to project" link.
if (payload.projectId !== undefined && await hasColumnCached('customer_hour_entries', 'project_id')) {
row.project_id = payload.projectId || null;
}
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];