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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user