From 0175007abc675ecb18a30e05241cac62bee833f8 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:20:59 +0200 Subject: [PATCH] feat(projects): gated project pickers on quote/contract/hours editors - ProjectSelect: a reusable picker that renders nothing when the projects flag is off (satisfies 'book to project hidden unless projects enabled'). - projects.service.ts: full frontend API client (list/get/create/update, overview, assign event/quote/contract, email preview + 4 actions). - Quote + contract editors carry an optional projectId (state, prefill, payload); service payload/detail types updated. - HoursSection gains a 'book to project' control; backend createEntry persists project_id (migration 118, hasColumnCached guarded). --- backend/src/routes/adminCustomers.js | 1 + backend/src/services/customerHoursService.js | 4 + .../src/components/admin/HoursSection.tsx | 12 ++ .../src/components/admin/ProjectSelect.tsx | 74 +++++++ .../admin/contracts/ContractEditorPage.tsx | 15 ++ .../pages/admin/quotes/QuoteEditorPage.tsx | 16 ++ frontend/src/services/contracts.service.ts | 6 + .../src/services/customerAdmin.service.ts | 2 + frontend/src/services/projects.service.ts | 180 ++++++++++++++++++ frontend/src/services/quotes.service.ts | 5 + 10 files changed, 315 insertions(+) create mode 100644 frontend/src/components/admin/ProjectSelect.tsx create mode 100644 frontend/src/services/projects.service.ts diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index cc03115c..95c43ec5 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -572,6 +572,7 @@ router.post('/:id/hour-entries', [ body('endTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/), body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }), body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }), + body('projectId').optional({ nullable: true }).isInt({ min: 1 }), ], handleAsync(async (req, res) => { validateRequest(req); const result = await customerHoursService.createEntry( diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index 2ce2e262..ab24efd6 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -233,6 +233,10 @@ async function createEntry(customerId, payload, adminId) { created_at: new Date(), updated_at: new Date(), }; + // Migration 118 — optional "book to project" link. + if (payload.projectId !== undefined && await hasColumnCached('customer_hour_entries', 'project_id')) { + row.project_id = payload.projectId || null; + } const inserted = await trx('customer_hour_entries').insert(row).returning('id'); const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index 6f4abd41..279a644f 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers'; import { customerAdminService } from '../../services/customerAdmin.service'; import { businessProfileService } from '../../services/businessProfile.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { ProjectSelect } from './ProjectSelect'; export interface HoursSectionProps { customerId: number; @@ -54,6 +55,8 @@ export const HoursSection: React.FC = ({ const [duration, setDuration] = useState(''); const [rateOverride, setRateOverride] = useState(''); const [description, setDescription] = useState(''); + // Migration 118 — optional "book to project" link (gated component). + const [projectId, setProjectId] = useState(null); // Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and // the end-time jumps to start + duration. Pure convenience; the End @@ -114,6 +117,7 @@ export const HoursSection: React.FC = ({ return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null; })(), description: description || null, + projectId: projectId ?? null, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] }); @@ -123,6 +127,7 @@ export const HoursSection: React.FC = ({ setDuration(''); setRateOverride(''); setDescription(''); + setProjectId(null); toast.success(t('customers.hours.toast.created', 'Entry logged')); }, onError: (err: any) => { @@ -348,6 +353,13 @@ export const HoursSection: React.FC = ({ placeholder={t('customers.hours.form.notePlaceholder', 'What was worked on?') as string} /> + {/* Book to project — renders only when the projects feature is on. */} +
{noRateConfigured && !overrideTyped && ( diff --git a/frontend/src/components/admin/ProjectSelect.tsx b/frontend/src/components/admin/ProjectSelect.tsx new file mode 100644 index 00000000..84703076 --- /dev/null +++ b/frontend/src/components/admin/ProjectSelect.tsx @@ -0,0 +1,74 @@ +/** + * ProjectSelect — a gated project picker reused by the quote / contract / + * hours / event editors to link a document to a Project Overview project. + * + * Renders nothing when the `projects` feature flag is off, so every call + * site stays a one-liner that simply vanishes when the feature is disabled + * (the maintainer's "book to project must not show unless projects is + * enabled" requirement). Customers never see this — admin surfaces only. + */ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; +import { projectsService } from '../../services/projects.service'; + +interface ProjectSelectProps { + value: number | null; + onChange: (projectId: number | null) => void; + /** Optional label above the select. When omitted the select renders bare. */ + label?: string; + /** Restrict the list to a single customer's projects when set. */ + customerAccountId?: number | null; + disabled?: boolean; + className?: string; +} + +export const ProjectSelect: React.FC = ({ + value, + onChange, + label, + customerAccountId, + disabled, + className, +}) => { + const { t } = useTranslation(); + const { flags } = useFeatureFlags(); + + const { data: projects, isLoading } = useQuery({ + queryKey: ['projects', 'select'], + queryFn: () => projectsService.list(), + enabled: !!flags.projects, + staleTime: 60_000, + }); + + // Hard gate: hidden entirely when the feature is off. + if (!flags.projects) return null; + + const options = (projects || []).filter( + (p) => customerAccountId == null || p.customerAccountId == null || p.customerAccountId === customerAccountId, + ); + + return ( +
+ {label && ( + + )} + +
+ ); +}; diff --git a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx index c06ed00a..b11ec9f0 100644 --- a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx +++ b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx @@ -24,6 +24,7 @@ import { CONTRACT_SECTIONS, } from '../../../services/contracts.service'; import { CustomerPicker } from '../../../components/admin/CustomerPicker'; +import { ProjectSelect } from '../../../components/admin/ProjectSelect'; interface BlockRow { blockId: number; @@ -63,6 +64,7 @@ export const ContractEditorPage: React.FC = () => { const [language, setLanguage] = useState('de'); const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10)); const [validUntil, setValidUntil] = useState(''); + const [projectId, setProjectId] = useState(null); const [blocks, setBlocks] = useState([]); // Load existing contract on edit. @@ -110,6 +112,7 @@ export const ContractEditorPage: React.FC = () => { setLanguage(c.language || 'de'); setIssueDate(c.issueDate); setValidUntil(c.validUntil || ''); + setProjectId(c.projectId ?? null); setBlocks((c.inclusions || []).map((inc) => ({ blockId: inc.blockId, section: inc.section, @@ -188,6 +191,7 @@ export const ContractEditorPage: React.FC = () => { outroText: outroText || null, issueDate, validUntil: validUntil || undefined, + projectId: projectId ?? null, }); // Apply block toggles + ordering as an update right after create. await contractsService.update(created.contract.id, { @@ -220,6 +224,7 @@ export const ContractEditorPage: React.FC = () => { language, issueDate, validUntil: validUntil || undefined, + projectId: projectId ?? null, blocks: blocks.map((b) => ({ blockId: b.blockId, included: b.included, position: b.position, })), @@ -348,6 +353,16 @@ export const ContractEditorPage: React.FC = () => {
)} + {/* Project link (renders only when the projects feature is on). */} +
+ +
+