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:
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user