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];
@@ -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<HoursSectionProps> = ({
const [duration, setDuration] = useState<string>('');
const [rateOverride, setRateOverride] = useState<string>('');
const [description, setDescription] = useState('');
// Migration 118 — optional "book to project" link (gated component).
const [projectId, setProjectId] = useState<number | null>(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<HoursSectionProps> = ({
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<HoursSectionProps> = ({
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<HoursSectionProps> = ({
placeholder={t('customers.hours.form.notePlaceholder',
'What was worked on?') as string} />
</div>
{/* Book to project — renders only when the projects feature is on. */}
<ProjectSelect
className="mt-3"
label={t('customers.hours.form.bookToProject', 'Book to project') as string}
value={projectId}
onChange={setProjectId}
/>
<div className="mt-3 flex items-center justify-end gap-3">
{noRateConfigured && !overrideTyped && (
<span className="text-xs text-amber-700 dark:text-amber-300">
@@ -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<ProjectSelectProps> = ({
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 (
<div className={className}>
{label && (
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</label>
)}
<select
value={value ?? ''}
disabled={disabled || isLoading}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-60"
>
<option value="">{t('projects.picker.none', 'No project')}</option>
{options.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
);
};
@@ -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 */}
@@ -109,6 +109,8 @@ export interface ContractSummary {
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
/** Migration 121 — Project Overview link (null when unlinked). */
projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -194,6 +196,8 @@ export interface ContractCreatePayload {
outroText?: string | null;
issueDate?: string;
validUntil?: string;
/** Migration 121 — optional link to a Project Overview project. */
projectId?: number | null;
}
export interface ContractUpdatePayload {
@@ -211,6 +215,8 @@ export interface ContractUpdatePayload {
* rows from this payload — caller controls inclusion + per-section
* order via the position field. Omit to leave inclusions untouched. */
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
/** Migration 121 — optional Project Overview link. null clears it. */
projectId?: number | null;
}
export interface ContractBlockCreatePayload {
@@ -451,6 +451,8 @@ export interface HourEntryCreatePayload {
endTime: string; // HH:MM
hourlyRateMinorOverride?: number | null;
description?: string | null;
/** Migration 118 — optional "book to project" link. */
projectId?: number | null;
}
export interface HourEntryUpdatePayload {
+180
View File
@@ -0,0 +1,180 @@
/**
* Admin → Projects API client. Hits /api/admin/projects/*.
*
* Projects are the admin-only grouping layer above events (Model A). The
* cockpit "overview" rolls up the per-event/per-customer documents. Mirrors
* the contracts/bills service shape: `data.data || data` unwrap.
*/
import { api } from '../config/api';
export type ProjectStatus = 'active' | 'archived' | string;
export interface ProjectSummary {
id: number;
name: string;
customerAccountId: number | null;
customerEmail: string | null;
status: ProjectStatus;
eventCount?: number;
createdAt: string | null;
updatedAt: string | null;
}
export interface ProjectEvent {
id: number;
event_name: string;
event_date: string | null;
slug: string;
is_active: boolean | number;
is_draft: boolean | number;
expires_at: string | null;
is_archived: boolean | number;
}
export interface ProjectEmail {
id: number;
recipient: string;
type: string;
status: string;
queuedAt: string | null;
sentAt: string | null;
error: string | null;
eventId: number | null;
}
export interface ProjectInvoice {
id: number;
invoice_number: string;
status: string;
kind: string | null;
issue_date: string | null;
due_date: string | null;
total_amount_minor: number;
paid_amount_minor: number | null;
paid_at: string | null;
currency: string;
event_id: number | null;
deal_uuid: string | null;
}
export interface ProjectQuote {
id: number;
quote_number: string;
status: string;
issue_date: string | null;
valid_until: string | null;
total_amount_minor: number;
currency: string;
deal_uuid: string | null;
}
export interface ProjectContract {
id: number;
contract_number: string;
status: string;
issue_date: string | null;
signed_by_customer_at: string | null;
deal_uuid: string | null;
}
export interface ProjectHourEntry {
id: number;
entry_date: string | null;
duration_minutes: number;
description: string | null;
status: string | null;
invoice_id: number | null;
}
export interface ProjectMilestone {
kind: 'quote' | 'contract' | 'gallery' | 'invoice';
label: string;
date: string | null;
}
export interface ProjectOverview {
project: ProjectSummary;
events: ProjectEvent[];
emails: ProjectEmail[];
quotes: ProjectQuote[];
contracts: ProjectContract[];
invoices: ProjectInvoice[];
hours: { entries: ProjectHourEntry[]; totalMinutes: number };
milestones: ProjectMilestone[];
}
export interface EmailPreview {
id: number;
recipient: string;
type: string;
status: string;
available: boolean;
html: string | null;
}
export const projectsService = {
async list(params: { q?: string; status?: string } = {}): Promise<ProjectSummary[]> {
const { data } = await api.get('/admin/projects', { params });
const body = data.data || data;
return body.projects || [];
},
async get(id: number): Promise<ProjectSummary> {
const { data } = await api.get(`/admin/projects/${id}`);
const body = data.data || data;
return body.project;
},
async create(payload: { name: string; customerAccountId?: number | null }): Promise<ProjectSummary> {
const { data } = await api.post('/admin/projects', payload);
const body = data.data || data;
return body.project;
},
async update(
id: number,
payload: { name?: string; customerAccountId?: number | null; status?: string },
): Promise<ProjectSummary> {
const { data } = await api.put(`/admin/projects/${id}`, payload);
const body = data.data || data;
return body.project;
},
async overview(id: number): Promise<ProjectOverview> {
const { data } = await api.get(`/admin/projects/${id}/overview`);
return (data.data || data) as ProjectOverview;
},
async assignEvent(projectId: number, eventId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/events`, { eventId });
},
async assignQuote(projectId: number, quoteId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/quotes`, { quoteId });
},
async assignContract(projectId: number, contractId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/contracts`, { contractId });
},
async emailPreview(emailId: number): Promise<EmailPreview> {
const { data } = await api.get(`/admin/projects/email/${emailId}/preview`);
return (data.data || data) as EmailPreview;
},
async resendEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/resend`);
},
async cancelEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/cancel`);
},
async retryEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/retry`);
},
async sendEmailNow(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/send-now`);
},
};
+5
View File
@@ -43,6 +43,8 @@ export interface QuoteSummary {
* doc — contract, invoices, Storni — that shares this deal. */
dealUuid: string | null;
customerAccountId: number;
/** Migration 121 — Project Overview link (null when unlinked). */
projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -195,6 +197,9 @@ export interface QuoteCreatePayload {
internalNotes?: string;
ccPdfEmail?: string;
businessBankAccountId?: number;
/** Migration 121 — optional link to a Project Overview project.
* null clears the link; undefined leaves it unchanged. */
projectId?: number | null;
lineItems: QuoteLineItem[];
}