feat(site-scope): exempt the Sites list, add a per-row scope action

This commit is contained in:
DEls 2026-09-11 23:20:05 +02:00
parent 5edea14105
commit 729118d5dd
3 changed files with 61 additions and 2 deletions

View file

@ -12,6 +12,8 @@ import {
} from "@/lib/metrics";
import { Topbar } from "@/components/layout/sidebar";
import { SitesView, type SiteSummary } from "./sites-view";
import { SetScopeButton } from "@/components/layout/set-scope-button";
import { getSiteScope, type SearchParams } from "@/lib/site-scope";
import {
AddSiteButton,
type SiteTemplateOption,
@ -19,9 +21,20 @@ import {
export const dynamic = "force-dynamic";
export default async function SitesPage() {
export default async function SitesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const pb = await pbAdmin();
// Sites list is intentionally exempt from scope (see plan §12):
// scoping it to one site would leave a one-row comparison table.
// We still read the allowed list so we can hide sites the user may
// not see, when PocketBase users land.
const scope = await getSiteScope(await searchParams);
const allowedIds = new Set(scope.allowed.map((s) => s.id));
const [
sites,
fixedCosts,
@ -117,7 +130,9 @@ export default async function SitesPage() {
incomeBySite.set(c.site, list);
}
const summaries: SiteSummary[] = sites.map((s) => {
const summaries: SiteSummary[] = sites
.filter((s) => allowedIds.has(s.id))
.map((s) => {
const siteFuel = fuelBySite.get(s.id) ?? [];
const spend = computeSiteSpend({

View file

@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { SetScopeButton } from "@/components/layout/set-scope-button";
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
export type SiteStatus = "just-started" | "under" | "on-track" | "over";
@ -239,6 +240,13 @@ function SiteCard({ site }: { site: SiteSummary }) {
{formatCurrency(site.projectedProfit)}
</span>
</div>
{/* Per-row scope action. Sits at the bottom of the card, on its
own row, so it never shares a hit target with the row's own
`Link` to the detail page. */}
<div className="flex items-center justify-end pt-1">
<SetScopeButton siteId={site.id} siteName={site.name} />
</div>
</CardContent>
</Card>
);

View file

@ -0,0 +1,36 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Crosshair } from "lucide-react";
import { SITE_SCOPE_COOKIE, SITE_SCOPE_PARAM } from "@/lib/site-scope";
/**
* Sets the global scope to one site from the Sites list. Deliberately
* NOT wired to the row's link: navigating must never silently mutate
* scope, so choosing a scope is its own explicit action.
*/
export function SetScopeButton({ siteId, siteName }: { siteId: string; siteName: string }) {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
return (
<button
type="button"
aria-label={`View only ${siteName}`}
title={`View only ${siteName}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
document.cookie = `${SITE_SCOPE_COOKIE}=${siteId}; path=/; max-age=31536000; samesite=lax`;
const next = new URLSearchParams(params.toString());
next.set(SITE_SCOPE_PARAM, siteId);
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Crosshair className="h-3.5 w-3.5" />
View only this site
</button>
);
}