fix(types): remove unnecessary as any casts in web components

- ProjectHealthWidget: row already typed as ProjectHealthRow with id field
- ResourceDetail: use narrowed unknown cast instead of any for error code
- provider.tsx: same pattern for TRPCClientError data access
- ChatPanel: use intersection type for Next.js typed route push

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:13:06 +02:00
parent 9051ff73d0
commit 0d79f97d7a
4 changed files with 431 additions and 206 deletions
@@ -3,24 +3,39 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@capakraken/shared";
import type {
AllocationLike,
AllocationReadModel,
AllocationWithDetails,
Resource,
SkillEntry,
} from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate, formatMoney } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js";
import { usePermissions } from "~/hooks/usePermissions.js";
const SkillRadarChart = dynamic(
() => import("~/components/resources/SkillRadarChart.js").then((mod) => ({ default: mod.SkillRadarChart })),
() =>
import("~/components/resources/SkillRadarChart.js").then((mod) => ({
default: mod.SkillRadarChart,
})),
{ ssr: false, loading: () => <div className="h-64 shimmer-skeleton rounded-xl" /> },
);
const AiSummaryCard = dynamic(
() => import("~/components/resources/AiSummaryCard.js").then((mod) => ({ default: mod.AiSummaryCard })),
() =>
import("~/components/resources/AiSummaryCard.js").then((mod) => ({
default: mod.AiSummaryCard,
})),
{ ssr: false },
);
const SkillMatrixUpload = dynamic(
() => import("~/components/resources/SkillMatrixUpload.js").then((mod) => ({ default: mod.SkillMatrixUpload })),
() =>
import("~/components/resources/SkillMatrixUpload.js").then((mod) => ({
default: mod.SkillMatrixUpload,
})),
{ ssr: false },
);
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -63,10 +78,25 @@ const allocationStatusColor: Record<string, string> = {
CANCELLED: "bg-red-100 text-red-500",
};
function StatCard({ label, value, sub, tooltip, ring }: { label: string; value: string | number; sub?: string; tooltip?: string; ring?: { value: number; color: string } }) {
function StatCard({
label,
value,
sub,
tooltip,
ring,
}: {
label: string;
value: string | number;
sub?: string;
tooltip?: string;
ring?: { value: number; color: string };
}) {
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="text-xs text-gray-500 mb-1 flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
<div className="text-xs text-gray-500 mb-1 flex items-center">
{label}
{tooltip && <InfoTooltip content={tooltip} />}
</div>
{ring ? (
<div className="flex items-center gap-3">
<ProgressRing value={ring.value} size={48} strokeWidth={3.5} color={ring.color}>
@@ -92,7 +122,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
const resource = _resourceQuery.data as unknown as Resource | undefined;
const loadingResource = _resourceQuery.isLoading;
const error = _resourceQuery.error;
const errorCode = (error as any)?.data?.code as string | undefined;
const errorCode = (error as unknown as { data?: { code?: string } } | null)?.data?.code;
// Fetch allocations for this resource (all non-cancelled)
const now = new Date();
@@ -101,10 +131,20 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{ resourceId },
{ enabled: !!resourceId },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array<Pick<
AllocationWithDetails,
"id" | "startDate" | "endDate" | "hoursPerDay" | "dailyCostCents" | "status" | "role" | "roleEntity" | "project"
>>;
const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array<
Pick<
AllocationWithDetails,
| "id"
| "startDate"
| "endDate"
| "hoursPerDay"
| "dailyCostCents"
| "status"
| "role"
| "roleEntity"
| "project"
>
>;
const loadingAllocations = _allocQuery.isLoading;
// Fetch upcoming/recent vacations
@@ -136,10 +176,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{ includeProposed: includeProposedChargeability, resourceId },
{ enabled: canViewCosts, staleTime: 60_000 },
);
const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{
actualChargeability: number;
expectedChargeability: number;
}> | undefined)?.[0];
const chargeStats = (
chargeabilityStatsResult.data as unknown as
| Array<{
actualChargeability: number;
expectedChargeability: number;
}>
| undefined
)?.[0];
if (loadingResource) {
return (
@@ -148,7 +192,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="h-8 shimmer-skeleton rounded w-64" />
<div className="h-4 shimmer-skeleton rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[0, 1, 2, 3].map((i) => <div key={i} className="h-20 shimmer-skeleton rounded-xl" style={{ animationDelay: `${i * 50}ms` }} />)}
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="h-20 shimmer-skeleton rounded-xl"
style={{ animationDelay: `${i * 50}ms` }}
/>
))}
</div>
</div>
</div>
@@ -160,7 +210,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
Resource not found.{" "}
<Link href="/resources" className="underline">Back to resources</Link>
<Link href="/resources" className="underline">
Back to resources
</Link>
</div>
</div>
);
@@ -171,16 +223,24 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="p-6">
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-xl p-6 text-amber-800 dark:text-amber-300 text-sm">
This resource could not be loaded right now.{" "}
<Link href="/resources" className="underline">Back to resources</Link>
<Link href="/resources" className="underline">
Back to resources
</Link>
</div>
</div>
);
}
const skills = resource.skills as unknown as SkillEntry[];
const resourceRoles = (resource as unknown as {
resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[];
}).resourceRoles ?? [];
const resourceRoles =
(
resource as unknown as {
resourceRoles?: {
isPrimary: boolean;
role: { id: string; name: string; color: string | null };
}[];
}
).resourceRoles ?? [];
const mainSkills = skills.filter((s) => s.isMainSkill);
// Determine if current user owns this resource (self-service)
@@ -257,10 +317,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">{resource.displayName}</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
{resource.displayName}
</h1>
<span
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
resource.isActive ? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" : "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
resource.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
}`}
>
{resource.isActive ? "Active" : "Inactive"}
@@ -269,7 +333,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<p className="text-sm text-gray-500 mt-1">
<span className="font-mono">{resource.eid}</span>
{" · "}
<a href={`mailto:${resource.email}`} className="hover:underline">{resource.email}</a>
<a href={`mailto:${resource.email}`} className="hover:underline">
{resource.email}
</a>
{resource.chapter && (
<>
{" · "}
@@ -285,8 +351,18 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
onClick={() => setUploadOpen(true)}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Update Skill Matrix
</button>
@@ -296,8 +372,18 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
onClick={() => setEditOpen(true)}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Edit
</button>
@@ -343,26 +429,29 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
}}
/>
{canViewCosts && (
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
tooltip="Actual chargeability = chargeable hours / total available hours x 100 for the current month."
sub={
includeProposedChargeability
? "Incl. proposed + imported TBD planning"
: "Confirmed + active only"
}
{...(chargeStats != null ? {
ring: {
value: chargeStats.actualChargeability,
color: chargeStats.actualChargeability >= resource.chargeabilityTarget
? "var(--color-green-500, #22c55e)"
: chargeStats.actualChargeability >= resource.chargeabilityTarget - 10
? "var(--color-amber-500, #f59e0b)"
: "var(--color-red-500, #ef4444)",
},
} : {})}
/>
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
tooltip="Actual chargeability = chargeable hours / total available hours x 100 for the current month."
sub={
includeProposedChargeability
? "Incl. proposed + imported TBD planning"
: "Confirmed + active only"
}
{...(chargeStats != null
? {
ring: {
value: chargeStats.actualChargeability,
color:
chargeStats.actualChargeability >= resource.chargeabilityTarget
? "var(--color-green-500, #22c55e)"
: chargeStats.actualChargeability >= resource.chargeabilityTarget - 10
? "var(--color-amber-500, #f59e0b)"
: "var(--color-red-500, #ef4444)",
},
}
: {})}
/>
)}
{canViewCosts && (
<StatCard
@@ -418,12 +507,16 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</div>
{/* Profile meta (area role, portfolio, last import) */}
{(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && (
{(resourceWithMeta.areaRole ||
resourceWithMeta.portfolioUrl ||
resourceWithMeta.skillMatrixUpdatedAt) && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4 flex flex-wrap gap-4 text-sm">
{resourceWithMeta.areaRole && (
<div className="flex items-center gap-2">
<span className="text-gray-500 dark:text-gray-400 text-xs">Area:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{resourceWithMeta.areaRole.name}</span>
<span className="font-medium text-gray-800 dark:text-gray-200">
{resourceWithMeta.areaRole.name}
</span>
</div>
)}
{resourceWithMeta.portfolioUrl && (
@@ -442,7 +535,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{resourceWithMeta.skillMatrixUpdatedAt && (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Skill matrix updated:</span>
<span className="text-gray-600">{formatDate(resourceWithMeta.skillMatrixUpdatedAt)}</span>
<span className="text-gray-600">
{formatDate(resourceWithMeta.skillMatrixUpdatedAt)}
</span>
</div>
)}
</div>
@@ -453,7 +548,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
resourceId={resourceId}
aiSummary={resourceWithMeta.aiSummary ?? null}
aiSummaryUpdatedAt={resourceWithMeta.aiSummaryUpdatedAt ?? null}
onGenerated={async () => { await utils.resource.getById.invalidate({ id: resourceId }); }}
onGenerated={async () => {
await utils.resource.getById.invalidate({ id: resourceId });
}}
/>
<section
@@ -477,7 +574,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Main Skills Badges */}
{mainSkills.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Main Skills<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." /></h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
Main Skills
<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." />
</h2>
<div className="flex flex-wrap gap-2">
{mainSkills.map((s) => (
<span
@@ -486,7 +586,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
>
<span className="text-amber-500"></span>
{s.skill}
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"}`}>
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${proficiencyColor[s.proficiency] ?? "bg-gray-100 text-gray-500"}`}
>
{proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`}
</span>
</span>
@@ -503,7 +605,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Roles */}
{resourceRoles.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Roles<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." /></h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
Roles
<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." />
</h2>
<div className="flex flex-wrap gap-2">
{resourceRoles.map((rr) => (
<span
@@ -526,7 +631,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Skills */}
{skills.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Skills<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." /></h2>
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
Skills
<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." />
</h2>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span
@@ -544,7 +652,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</span>
)}
{s.yearsExperience != null && (
<span className="text-xs text-gray-400 dark:text-gray-500">{s.yearsExperience}y</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
{s.yearsExperience}y
</span>
)}
</span>
))}
@@ -569,7 +679,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Role</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Period</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">h/Day</th>
{canViewCosts && <th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Daily Cost</th>}
{canViewCosts && (
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">
Daily Cost
</th>
)}
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Status</th>
</tr>
</thead>
@@ -581,25 +695,29 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<td className="px-4 py-3">
{a.project ? (
<>
<span className="font-mono text-xs text-gray-500 mr-1">{a.project.shortCode}</span>
<span className="font-mono text-xs text-gray-500 mr-1">
{a.project.shortCode}
</span>
{a.project.name}
</>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-gray-600">{a.role ?? (a.roleEntity?.name ?? "—")}</td>
<td className="px-4 py-3 text-gray-600">
{a.role ?? a.roleEntity?.name ?? "—"}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{formatDate(a.startDate)} {formatDate(a.endDate)}
</td>
<td className={`px-4 py-3 text-right font-medium ${isOver ? "text-amber-600" : "text-gray-900"}`}>
<td
className={`px-4 py-3 text-right font-medium ${isOver ? "text-amber-600" : "text-gray-900"}`}
>
{a.hoursPerDay}h
</td>
{canViewCosts && (
<td className="px-4 py-3 text-right text-gray-700">
{a.dailyCostCents > 0
? `${formatMoney(a.dailyCostCents)}/d`
: "—"}
{a.dailyCostCents > 0 ? `${formatMoney(a.dailyCostCents)}/d` : "—"}
</td>
)}
<td className="px-4 py-3 text-right">
@@ -642,7 +760,8 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{vacationList.map((v) => {
const days =
Math.round(
(new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) / (1000 * 60 * 60 * 24),
(new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) /
(1000 * 60 * 60 * 24),
) + 1;
return (
<div key={v.id} className="px-5 py-3 flex items-center justify-between gap-4">
@@ -652,10 +771,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(v.startDate)} {formatDate(v.endDate)}
<span className="ml-1 text-gray-400">({days} day{days !== 1 ? "s" : ""})</span>
<span className="ml-1 text-gray-400">
({days} day{days !== 1 ? "s" : ""})
</span>
</div>
{v.note && (
<div className="text-xs text-gray-400 mt-0.5 italic truncate max-w-sm">{v.note}</div>
<div className="text-xs text-gray-400 mt-0.5 italic truncate max-w-sm">
{v.note}
</div>
)}
</div>
<span