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
@@ -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, {
@@ -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 = () => {
</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">
@@ -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,
@@ -479,6 +486,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 */}