refactor(web): remove unnecessary "use client" from 6 pure-render components

BenchResourceCard, MobileProjectCard, MobileCapacityCard, DynamicFieldRenderer,
BudgetStatusBar, and TimelineHeader use no hooks, event handlers, or browser APIs —
they can be server components, reducing client bundle size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 23:36:34 +02:00
parent e08ee94546
commit 8f7c69056f
6 changed files with 122 additions and 65 deletions
@@ -1,5 +1,3 @@
"use client";
import Link from "next/link"; import Link from "next/link";
interface BenchResourceCardProps { interface BenchResourceCardProps {
@@ -29,11 +27,7 @@ export function BenchResourceCard({
.join(""); .join("");
const availabilityLevel = const availabilityLevel =
availableHoursPerDay >= 6 availableHoursPerDay >= 6 ? "high" : availableHoursPerDay >= 3 ? "medium" : "low";
? "high"
: availableHoursPerDay >= 3
? "medium"
: "low";
const levelClass = const levelClass =
availabilityLevel === "high" availabilityLevel === "high"
@@ -55,10 +49,14 @@ export function BenchResourceCard({
<div className={`rounded-xl border p-4 space-y-3 ${levelClass}`}> <div className={`rounded-xl border p-4 space-y-3 ${levelClass}`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="h-10 w-10 shrink-0 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center"> <div className="h-10 w-10 shrink-0 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center">
<span className="text-sm font-semibold text-brand-700 dark:text-brand-300">{initials}</span> <span className="text-sm font-semibold text-brand-700 dark:text-brand-300">
{initials}
</span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">{name}</div> <div className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{eid}</div> <div className="text-xs text-gray-500 dark:text-gray-400">{eid}</div>
</div> </div>
</div> </div>
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js"; import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@capakraken/shared"; import { FieldType } from "@capakraken/shared";
@@ -36,9 +34,7 @@ function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.
<span <span
className={clsx( className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium", "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
bool bool ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500",
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500",
)} )}
> >
{bool ? "Yes" : "No"} {bool ? "Yes" : "No"}
@@ -100,9 +96,7 @@ function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; val
{fieldDef.label} {fieldDef.label}
</dt> </dt>
<dd className="text-sm">{renderValue(fieldDef, value)}</dd> <dd className="text-sm">{renderValue(fieldDef, value)}</dd>
{fieldDef.description && ( {fieldDef.description && <p className="text-xs text-gray-400">{fieldDef.description}</p>}
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
</div> </div>
); );
} }
@@ -1,5 +1,3 @@
"use client";
interface MobileCapacityCardProps { interface MobileCapacityCardProps {
totalResources: number; totalResources: number;
activeResources: number; activeResources: number;
@@ -16,8 +14,7 @@ export function MobileCapacityCard({
const pct = Math.min(100, Math.max(0, avgUtilizationPct)); const pct = Math.min(100, Math.max(0, avgUtilizationPct));
const circumference = 2 * Math.PI * 34; // radius = 34 const circumference = 2 * Math.PI * 34; // radius = 34
const dashOffset = circumference * (1 - pct / 100); const dashOffset = circumference * (1 - pct / 100);
const color = const color = pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280";
pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280";
return ( return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5"> <div className="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
@@ -27,7 +24,15 @@ export function MobileCapacityCard({
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
{/* CSS-only donut */} {/* CSS-only donut */}
<svg width="80" height="80" viewBox="0 0 80 80" className="shrink-0"> <svg width="80" height="80" viewBox="0 0 80 80" className="shrink-0">
<circle cx="40" cy="40" r="34" fill="none" stroke="#e5e7eb" strokeWidth="8" className="dark:stroke-gray-700" /> <circle
cx="40"
cy="40"
r="34"
fill="none"
stroke="#e5e7eb"
strokeWidth="8"
className="dark:stroke-gray-700"
/>
<circle <circle
cx="40" cx="40"
cy="40" cy="40"
@@ -40,7 +45,15 @@ export function MobileCapacityCard({
strokeLinecap="round" strokeLinecap="round"
transform="rotate(-90 40 40)" transform="rotate(-90 40 40)"
/> />
<text x="40" y="40" textAnchor="middle" dominantBaseline="middle" fontSize="15" fontWeight="700" fill={color}> <text
x="40"
y="40"
textAnchor="middle"
dominantBaseline="middle"
fontSize="15"
fontWeight="700"
fill={color}
>
{Math.round(pct)}% {Math.round(pct)}%
</text> </text>
</svg> </svg>
@@ -54,7 +67,9 @@ export function MobileCapacityCard({
{overbookedCount > 0 && ( {overbookedCount > 0 && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-amber-600 dark:text-amber-400">Overbooked</span> <span className="text-amber-600 dark:text-amber-400">Overbooked</span>
<span className="font-semibold text-amber-600 dark:text-amber-400">{overbookedCount}</span> <span className="font-semibold text-amber-600 dark:text-amber-400">
{overbookedCount}
</span>
</div> </div>
)} )}
</div> </div>
@@ -1,5 +1,3 @@
"use client";
import Link from "next/link"; import Link from "next/link";
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, string> = {
@@ -18,20 +16,32 @@ interface MobileProjectCardProps {
allocationsCount?: number; allocationsCount?: number;
} }
export function MobileProjectCard({ id, shortCode, name, status, allocationsCount }: MobileProjectCardProps) { export function MobileProjectCard({
id,
shortCode,
name,
status,
allocationsCount,
}: MobileProjectCardProps) {
return ( return (
<Link <Link
href={`/projects/${id}`} href={`/projects/${id}`}
className="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" className="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
> >
<div className="font-mono text-xs text-gray-500 dark:text-gray-400 w-16 shrink-0">{shortCode}</div> <div className="font-mono text-xs text-gray-500 dark:text-gray-400 w-16 shrink-0">
{shortCode}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{name}</div> <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{name}</div>
{allocationsCount !== undefined && ( {allocationsCount !== undefined && (
<div className="text-xs text-gray-500 dark:text-gray-400">{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}</div> <div className="text-xs text-gray-500 dark:text-gray-400">
{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}
</div>
)} )}
</div> </div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[status] ?? STATUS_BADGE["DRAFT"]}`}> <span
className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[status] ?? STATUS_BADGE["DRAFT"]}`}
>
{status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")} {status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")}
</span> </span>
</Link> </Link>
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js"; import { formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -55,9 +53,13 @@ export function BudgetStatusBar({
// Cap visual bar segments at 100% total // Cap visual bar segments at 100% total
const cappedConfirmedPercent = Math.min(confirmedPercent, 100); const cappedConfirmedPercent = Math.min(confirmedPercent, 100);
const cappedProposedPercent = Math.min(proposedPercent, Math.max(0, 100 - cappedConfirmedPercent)); const cappedProposedPercent = Math.min(
proposedPercent,
Math.max(0, 100 - cappedConfirmedPercent),
);
const highestWarning = warnings.length > 0 const highestWarning =
warnings.length > 0
? warnings.reduce((prev, curr) => { ? warnings.reduce((prev, curr) => {
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 }; const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev; return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
@@ -74,12 +76,18 @@ export function BudgetStatusBar({
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden"> <div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
{/* Confirmed segment */} {/* Confirmed segment */}
<div <div
className={clsx("absolute left-0 top-0 h-full transition-all", getConfirmedBarColor(utilizationPercent))} className={clsx(
"absolute left-0 top-0 h-full transition-all",
getConfirmedBarColor(utilizationPercent),
)}
style={{ width: `${cappedConfirmedPercent}%` }} style={{ width: `${cappedConfirmedPercent}%` }}
/> />
{/* Proposed segment */} {/* Proposed segment */}
<div <div
className={clsx("absolute top-0 h-full transition-all", getProposedBarColor(utilizationPercent))} className={clsx(
"absolute top-0 h-full transition-all",
getProposedBarColor(utilizationPercent),
)}
style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }} style={{ left: `${cappedConfirmedPercent}%`, width: `${cappedProposedPercent}%` }}
/> />
</div> </div>
@@ -89,8 +97,7 @@ export function BudgetStatusBar({
<span> <span>
<span className="font-medium">{formatEur(allocatedCents)}</span> <span className="font-medium">{formatEur(allocatedCents)}</span>
{" / "} {" / "}
<span>{formatEur(budgetCents)}</span> <span>{formatEur(budgetCents)}</span>{" "}
{" "}
<span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span> <span className="text-gray-400">({utilizationPercent.toFixed(1)}%)</span>
</span> </span>
@@ -102,12 +109,20 @@ export function BudgetStatusBar({
getWarningBadgeStyle(highestWarning.level), getWarningBadgeStyle(highestWarning.level),
)} )}
> >
{highestWarning.level === "critical" ? "⚠" : highestWarning.level === "warning" ? "!" : "i"} {highestWarning.level === "critical"
? "⚠"
: highestWarning.level === "warning"
? "!"
: "i"}
{warnings.length > 1 ? `${warnings.length} warnings` : "Warning"} {warnings.length > 1 ? `${warnings.length} warnings` : "Warning"}
</span> </span>
)} )}
<span className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}> <span
{remainingCents >= 0 ? `${formatEur(remainingCents)} left` : `${formatEur(Math.abs(remainingCents))} over`} className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}
>
{remainingCents >= 0
? `${formatEur(remainingCents)} left`
: `${formatEur(Math.abs(remainingCents))} over`}
</span> </span>
</div> </div>
</div> </div>
@@ -115,11 +130,21 @@ export function BudgetStatusBar({
{/* Legend */} {/* Legend */}
<div className="flex items-center gap-3 text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getConfirmedBarColor(utilizationPercent))} /> <span
className={clsx(
"inline-block w-2.5 h-2.5 rounded-sm",
getConfirmedBarColor(utilizationPercent),
)}
/>
Confirmed {formatEur(confirmedCents)} Confirmed {formatEur(confirmedCents)}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className={clsx("inline-block w-2.5 h-2.5 rounded-sm", getProposedBarColor(utilizationPercent))} /> <span
className={clsx(
"inline-block w-2.5 h-2.5 rounded-sm",
getProposedBarColor(utilizationPercent),
)}
/>
Proposed {formatEur(proposedCents)} Proposed {formatEur(proposedCents)}
</span> </span>
</div> </div>
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { MONTHS_SHORT } from "./timelineConstants.js"; import { MONTHS_SHORT } from "./timelineConstants.js";
@@ -33,7 +31,10 @@ export function TimelineHeader({
className="sticky top-0 z-40 flex bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800" className="sticky top-0 z-40 flex bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800"
style={{ height: HEADER_MONTH_HEIGHT }} style={{ height: HEADER_MONTH_HEIGHT }}
> >
<div className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700" style={{ width: LABEL_WIDTH }} /> <div
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700"
style={{ width: LABEL_WIDTH }}
/>
<div className="flex"> <div className="flex">
{monthGroups.map((m, i) => ( {monthGroups.map((m, i) => (
<div <div
@@ -72,27 +73,41 @@ export function TimelineHeader({
key={i} key={i}
className={clsx( className={clsx(
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden", "flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
isToday ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800" : isToday
isWeekend ? "bg-brand-50/60 dark:bg-brand-950/30 border-brand-200 dark:border-brand-800" : ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800"
isMonday ? "border-gray-200 dark:border-gray-700" : "border-gray-100 dark:border-gray-800", : isWeekend
? "bg-brand-50/60 dark:bg-brand-950/30 border-brand-200 dark:border-brand-800"
: isMonday
? "border-gray-200 dark:border-gray-700"
: "border-gray-100 dark:border-gray-800",
)} )}
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }} style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
> >
{showLabel && ( {showLabel && (
<> <>
<span className={clsx( <span
className={clsx(
"font-medium leading-none", "font-medium leading-none",
isToday ? "text-brand-600" : isWeekend ? "text-brand-600 dark:text-brand-400" : "text-gray-600 dark:text-gray-300", isToday
)}> ? "text-brand-600"
: isWeekend
? "text-brand-600 dark:text-brand-400"
: "text-gray-600 dark:text-gray-300",
)}
>
{zoom === "week" {zoom === "week"
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}` ? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
: date.getDate()} : date.getDate()}
</span> </span>
{zoom === "day" && ( {zoom === "day" && (
<span className={clsx( <span
className={clsx(
"text-[9px] leading-none mt-0.5", "text-[9px] leading-none mt-0.5",
isWeekend ? "text-brand-400 dark:text-brand-500" : "text-gray-300 dark:text-gray-600", isWeekend
)}> ? "text-brand-400 dark:text-brand-500"
: "text-gray-300 dark:text-gray-600",
)}
>
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][dow]} {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][dow]}
</span> </span>
)} )}