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";
interface BenchResourceCardProps {
@@ -29,11 +27,7 @@ export function BenchResourceCard({
.join("");
const availabilityLevel =
availableHoursPerDay >= 6
? "high"
: availableHoursPerDay >= 3
? "medium"
: "low";
availableHoursPerDay >= 6 ? "high" : availableHoursPerDay >= 3 ? "medium" : "low";
const levelClass =
availabilityLevel === "high"
@@ -55,10 +49,14 @@ export function BenchResourceCard({
<div className={`rounded-xl border p-4 space-y-3 ${levelClass}`}>
<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">
<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 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>
</div>
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@capakraken/shared";
@@ -36,9 +34,7 @@ function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.
<span
className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
bool
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500",
bool ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500",
)}
>
{bool ? "Yes" : "No"}
@@ -100,9 +96,7 @@ function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; val
{fieldDef.label}
</dt>
<dd className="text-sm">{renderValue(fieldDef, value)}</dd>
{fieldDef.description && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
{fieldDef.description && <p className="text-xs text-gray-400">{fieldDef.description}</p>}
</div>
);
}
@@ -1,5 +1,3 @@
"use client";
interface MobileCapacityCardProps {
totalResources: number;
activeResources: number;
@@ -16,8 +14,7 @@ export function MobileCapacityCard({
const pct = Math.min(100, Math.max(0, avgUtilizationPct));
const circumference = 2 * Math.PI * 34; // radius = 34
const dashOffset = circumference * (1 - pct / 100);
const color =
pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280";
const color = pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280";
return (
<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">
{/* CSS-only donut */}
<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
cx="40"
cy="40"
@@ -40,7 +45,15 @@ export function MobileCapacityCard({
strokeLinecap="round"
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)}%
</text>
</svg>
@@ -54,7 +67,9 @@ export function MobileCapacityCard({
{overbookedCount > 0 && (
<div className="flex items-center justify-between text-sm">
<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>
@@ -1,11 +1,9 @@
"use client";
import Link from "next/link";
const STATUS_BADGE: Record<string, string> = {
ACTIVE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
DRAFT: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
ON_HOLD: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
ACTIVE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
DRAFT: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
ON_HOLD: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
COMPLETED: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
CANCELLED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
};
@@ -18,20 +16,32 @@ interface MobileProjectCardProps {
allocationsCount?: number;
}
export function MobileProjectCard({ id, shortCode, name, status, allocationsCount }: MobileProjectCardProps) {
export function MobileProjectCard({
id,
shortCode,
name,
status,
allocationsCount,
}: MobileProjectCardProps) {
return (
<Link
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"
>
<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="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{name}</div>
{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>
<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("_", " ")}
</span>
</Link>
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -55,14 +53,18 @@ export function BudgetStatusBar({
// Cap visual bar segments at 100% total
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
? warnings.reduce((prev, curr) => {
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
})
: null;
const highestWarning =
warnings.length > 0
? warnings.reduce((prev, curr) => {
const levels: Record<string, number> = { info: 0, warning: 1, critical: 2 };
return (levels[curr.level] ?? 0) > (levels[prev.level] ?? 0) ? curr : prev;
})
: null;
return (
<div className={clsx("space-y-1.5", className)}>
@@ -74,12 +76,18 @@ export function BudgetStatusBar({
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
{/* Confirmed segment */}
<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}%` }}
/>
{/* Proposed segment */}
<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}%` }}
/>
</div>
@@ -89,8 +97,7 @@ export function BudgetStatusBar({
<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>
@@ -102,12 +109,20 @@ export function BudgetStatusBar({
getWarningBadgeStyle(highestWarning.level),
)}
>
{highestWarning.level === "critical" ? "⚠" : highestWarning.level === "warning" ? "!" : "i"}
{highestWarning.level === "critical"
? "⚠"
: highestWarning.level === "warning"
? "!"
: "i"}
{warnings.length > 1 ? `${warnings.length} warnings` : "Warning"}
</span>
)}
<span className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}>
{remainingCents >= 0 ? `${formatEur(remainingCents)} left` : `${formatEur(Math.abs(remainingCents))} over`}
<span
className={clsx("font-medium", remainingCents < 0 ? "text-red-600" : "text-gray-700")}
>
{remainingCents >= 0
? `${formatEur(remainingCents)} left`
: `${formatEur(Math.abs(remainingCents))} over`}
</span>
</div>
</div>
@@ -115,11 +130,21 @@ export function BudgetStatusBar({
{/* Legend */}
<div className="flex items-center gap-3 text-xs text-gray-500">
<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)}
</span>
<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)}
</span>
</div>
@@ -1,5 +1,3 @@
"use client";
import { clsx } from "clsx";
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"
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">
{monthGroups.map((m, i) => (
<div
@@ -72,27 +73,41 @@ export function TimelineHeader({
key={i}
className={clsx(
"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" :
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",
isToday
? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-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 }}
>
{showLabel && (
<>
<span className={clsx(
"font-medium leading-none",
isToday ? "text-brand-600" : isWeekend ? "text-brand-600 dark:text-brand-400" : "text-gray-600 dark:text-gray-300",
)}>
<span
className={clsx(
"font-medium leading-none",
isToday
? "text-brand-600"
: isWeekend
? "text-brand-600 dark:text-brand-400"
: "text-gray-600 dark:text-gray-300",
)}
>
{zoom === "week"
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
: date.getDate()}
</span>
{zoom === "day" && (
<span className={clsx(
"text-[9px] leading-none mt-0.5",
isWeekend ? "text-brand-400 dark:text-brand-500" : "text-gray-300 dark:text-gray-600",
)}>
<span
className={clsx(
"text-[9px] leading-none mt-0.5",
isWeekend
? "text-brand-400 dark:text-brand-500"
: "text-gray-300 dark:text-gray-600",
)}
>
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][dow]}
</span>
)}