From 236932325971e23db17d6245d1ab2e9f2c1b4eec Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:05:49 +0200 Subject: [PATCH] fix(projects): only link cockpit rows when the target feature is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cockpit surfaces quotes/invoices/contracts by PERMISSION, but their detail routes are gated by feature FLAG (RequireFeature). With those flags off, clicking a row navigated to a route that redirects to /admin/dashboard — so links 'did nothing' while the email action buttons (plain API calls) worked. hrefFor now returns null when the destination flag is off, so the row renders as non-clickable text instead of a dead link. Galleries/events are never flag-gated, so they always link. --- .../admin/projects/ProjectCockpitPage.tsx | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx index 593bba12..e3088830 100644 --- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx +++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx @@ -25,6 +25,7 @@ import { import { eventsService } from '../../../services/events.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { formatMoneyMinor } from '../../../utils/money'; +import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext'; type FeedKind = 'email' | 'quote' | 'contract' | 'invoice' | 'gallery' | 'hours'; @@ -42,10 +43,28 @@ interface FeedItem { reRendered?: boolean; } +/** The feature flag that gates each document's detail ROUTE (RequireFeature + * in App.tsx). The cockpit surfaces docs by permission, but their detail + * pages live behind these flags — so a link is only live when the flag is + * on, else clicking would bounce to /admin/dashboard. Galleries/events have + * no such gate. */ +const FLAG_FOR_KIND: Partial> = { + quote: 'quotes', + contract: 'contracts', + invoice: 'bills', +}; + /** Detail-page route for a clickable document, or null when there isn't one - * (hours have no standalone page; emails open the preview instead). */ -function hrefFor(kind: FeedKind | ProjectMilestone['kind'], id?: number): string | null { + * (hours have no page; emails open the preview) OR the destination's feature + * flag is off (so we don't render a link that just redirects away). */ +function hrefFor( + kind: FeedKind | ProjectMilestone['kind'], + id: number | undefined, + flags: Record, +): string | null { if (id == null) return null; + const flag = FLAG_FOR_KIND[kind as FeedKind]; + if (flag && !flags[flag]) return null; switch (kind) { case 'quote': return `/admin/clients/quotes/${id}`; case 'contract': return `/admin/clients/contracts/${id}`; @@ -76,6 +95,7 @@ export const ProjectCockpitPage: React.FC = () => { const projectId = id ? parseInt(id, 10) : null; const navigate = useNavigate(); const qc = useQueryClient(); + const { flags } = useFeatureFlags(); const { format, formatTime } = useLocalizedDate(); const [editName, setEditName] = useState(null); @@ -162,14 +182,14 @@ export const ProjectCockpitPage: React.FC = () => { key: `quote-${q.id}`, kind: 'quote', date: q.issue_date, title: t('projects.feed.quote', 'Quote') + ` ${q.quote_number}`, status: q.status, amount: formatMoneyMinor(Number(q.total_amount_minor), q.currency), - href: hrefFor('quote', q.id), + href: hrefFor('quote', q.id, flags), }); } for (const c of data.contracts) { items.push({ key: `contract-${c.id}`, kind: 'contract', date: c.issue_date, title: t('projects.feed.contract', 'Contract') + ` ${c.contract_number}`, - status: c.status, href: hrefFor('contract', c.id), + status: c.status, href: hrefFor('contract', c.id, flags), }); } for (const inv of data.invoices) { @@ -177,14 +197,14 @@ export const ProjectCockpitPage: React.FC = () => { key: `invoice-${inv.id}`, kind: 'invoice', date: inv.issue_date, title: t('projects.feed.invoice', 'Invoice') + ` ${inv.invoice_number}`, status: inv.status, amount: formatMoneyMinor(Number(inv.total_amount_minor), inv.currency), - href: hrefFor('invoice', inv.id), + href: hrefFor('invoice', inv.id, flags), }); } for (const ev of data.events) { items.push({ key: `gallery-${ev.id}`, kind: 'gallery', date: ev.event_date, title: t('projects.feed.gallery', 'Gallery') + ` · ${ev.event_name}`, - subtitle: ev.slug, href: hrefFor('gallery', ev.id), + subtitle: ev.slug, href: hrefFor('gallery', ev.id, flags), }); } for (const h of data.hours.entries) { @@ -199,7 +219,7 @@ export const ProjectCockpitPage: React.FC = () => { const db = b.date ? new Date(b.date).getTime() : 0; return db - da; }); - }, [data, t]); + }, [data, t, flags]); if (isLoading) return ; if (!data) return
{t('projects.notFound', 'Project not found')}
; @@ -309,7 +329,7 @@ export const ProjectCockpitPage: React.FC = () => {
{milestones.map((m, i) => { const Icon = KIND_ICON[m.kind] || FileText; - const href = hrefFor(m.kind, m.id); + const href = hrefFor(m.kind, m.id, flags); return (