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 { 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>
);
};