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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user