"use client"; import { useState } from "react"; import Link from "next/link"; import dynamic from "next/dynamic"; 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 })), { ssr: false, loading: () =>
}, ); const AiSummaryCard = dynamic( () => 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 })), { ssr: false }, ); import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { ProgressRing } from "~/components/ui/ProgressRing.js"; import { FadeIn } from "~/components/ui/FadeIn.js"; interface ResourceDetailProps { resourceId: string; } const proficiencyLabel: Record = { 1: "Beginner", 2: "Elementary", 3: "Intermediate", 4: "Advanced", 5: "Expert", }; const proficiencyColor: Record = { 1: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300", 2: "bg-blue-50 text-blue-600 dark:bg-blue-900/50 dark:text-blue-300", 3: "bg-brand-50 text-brand-700 dark:bg-brand-900/50 dark:text-brand-200", 4: "bg-amber-50 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", 5: "bg-green-50 text-green-700 dark:bg-green-900/50 dark:text-green-300", }; const vacationStatusColor: Record = { PENDING: "bg-yellow-100 text-yellow-700", APPROVED: "bg-green-100 text-green-700", REJECTED: "bg-red-100 text-red-700", CANCELLED: "bg-gray-100 text-gray-500", }; const allocationStatusColor: Record = { PROPOSED: "bg-gray-100 text-gray-600", CONFIRMED: "bg-blue-100 text-blue-700", ACTIVE: "bg-green-100 text-green-700", COMPLETED: "bg-purple-100 text-purple-700", 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 } }) { return (
{label}{tooltip && }
{ring ? (
{value}
) : (
{value}
)} {sub &&
{sub}
}
); } export function ResourceDetail({ resourceId }: ResourceDetailProps) { const [editOpen, setEditOpen] = useState(false); const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); const utils = trpc.useUtils(); const { canViewCosts, canEdit, canViewScores } = usePermissions(); const _resourceQuery = trpc.resource.getById.useQuery({ id: resourceId }); const resource = _resourceQuery.data as unknown as Resource | undefined; const loadingResource = _resourceQuery.isLoading; const error = _resourceQuery.error; // Fetch allocations for this resource (all non-cancelled) const now = new Date(); const windowEnd = new Date(now); windowEnd.setDate(windowEnd.getDate() + 90); const _allocQuery = trpc.allocation.listView.useQuery( { resourceId }, { enabled: !!resourceId }, ) as { data: AllocationReadModel | undefined; isLoading: boolean }; const allocations = (_allocQuery.data?.assignments ?? []) as unknown as Array>; const loadingAllocations = _allocQuery.isLoading; // Fetch upcoming/recent vacations const vacationStart = new Date(now); vacationStart.setMonth(vacationStart.getMonth() - 1); const { data: vacations, isLoading: loadingVacations } = trpc.vacation.list.useQuery( { resourceId, startDate: vacationStart, limit: 20, }, { enabled: !!resourceId }, ); const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery( { includeProposed: includeProposedChargeability, resourceId }, { enabled: canViewCosts, staleTime: 60_000 }, ); const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{ actualChargeability: number; expectedChargeability: number; }> | undefined)?.[0]; if (loadingResource) { return (
{[0, 1, 2, 3].map((i) =>
)}
); } if (error || !resource) { return (
Resource not found.{" "} Back to resources
); } 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 mainSkills = skills.filter((s) => s.isMainSkill); // Determine if current user owns this resource (self-service) const resourceWithMeta = resource as unknown as { userId?: string | null; portfolioUrl?: string | null; roleId?: string | null; aiSummary?: string | null; aiSummaryUpdatedAt?: Date | null; skillMatrixUpdatedAt?: Date | null; areaRole?: { name: string } | null; valueScore?: number | null; valueScoreBreakdown?: { skillDepth: number; skillBreadth: number; costEfficiency: number; chargeability: number; experience: number; total: number; } | null; valueScoreUpdatedAt?: Date | null; isOwnedByCurrentUser?: boolean; }; const isOwner = resourceWithMeta.isOwnedByCurrentUser === true; const canUpload = isOwner || canEdit; // Compute stats const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); const thisMonthAllocs = (allocations ?? []).filter((a) => { const start = new Date(a.startDate); const end = new Date(a.endDate); return start <= monthEnd && end >= monthStart; }); let totalHoursThisMonth = 0; let totalCostCentsThisMonth = 0; const activeProjectIds = new Set(); for (const a of thisMonthAllocs) { const start = new Date(Math.max(new Date(a.startDate).getTime(), monthStart.getTime())); const end = new Date(Math.min(new Date(a.endDate).getTime(), monthEnd.getTime())); const days = Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1); totalHoursThisMonth += a.hoursPerDay * days; totalCostCentsThisMonth += a.dailyCostCents * days; if (a.project?.id) activeProjectIds.add(a.project.id); } const avgDailyCost = thisMonthAllocs.length > 0 ? Math.round(totalCostCentsThisMonth / 100 / (thisMonthAllocs.length || 1)) : 0; // Filter upcoming/active allocations (not cancelled, ending >= today) const upcomingAllocations = (allocations ?? []).filter( (a) => a.status !== "CANCELLED" && new Date(a.endDate) >= now, ); return (
{/* Back navigation */} Back to Resources {/* Header */}

{resource.displayName}

{resource.isActive ? "Active" : "Inactive"}

{resource.eid} {" · "} {resource.email} {resource.chapter && ( <> {" · "} {resource.chapter} )}

{canUpload && ( )}
{/* Stats cards */} {canViewCosts && (
)}
{canViewCosts && ( )} {canViewCosts && ( )} {canViewCosts && ( = resource.chargeabilityTarget ? "var(--color-green-500, #22c55e)" : chargeStats.actualChargeability >= resource.chargeabilityTarget - 10 ? "var(--color-amber-500, #f59e0b)" : "var(--color-red-500, #ef4444)", }, } : {})} /> )} {canViewCosts && ( )} {canViewScores && resourceWithMeta.valueScore != null && (
{resourceWithMeta.valueScoreBreakdown && (

Score Breakdown

{( [ ["Skill Depth", resourceWithMeta.valueScoreBreakdown.skillDepth], ["Skill Breadth", resourceWithMeta.valueScoreBreakdown.skillBreadth], ["Cost Efficiency", resourceWithMeta.valueScoreBreakdown.costEfficiency], ["Chargeability", resourceWithMeta.valueScoreBreakdown.chargeability], ["Experience", resourceWithMeta.valueScoreBreakdown.experience], ] as [string, number][] ).map(([label, val]) => (
{label} {val}
= 70 ? "bg-green-500" : val >= 40 ? "bg-amber-400" : "bg-red-400"}`} style={{ width: `${val}%` }} />
))}
)}
)}
{/* Profile meta (area role, portfolio, last import) */} {(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && (
{resourceWithMeta.areaRole && (
Area: {resourceWithMeta.areaRole.name}
)} {resourceWithMeta.portfolioUrl && ( )} {resourceWithMeta.skillMatrixUpdatedAt && (
Skill matrix updated: {formatDate(resourceWithMeta.skillMatrixUpdatedAt)}
)}
)} {/* AI Summary */} { await utils.resource.getById.invalidate({ id: resourceId }); }} /> {/* Main Skills Badges */} {mainSkills.length > 0 && (

Main Skills

{mainSkills.map((s) => ( {s.skill} {proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`} ))}
)} {/* Skill Radar Chart */} {/* Roles */} {resourceRoles.length > 0 && (

Roles

{resourceRoles.map((rr) => ( {rr.isPrimary && } {rr.role.name} {rr.isPrimary && Primary} ))}
)} {/* Skills */} {skills.length > 0 && (

Skills

{skills.map((s) => ( {s.skill} {s.proficiency != null && ( {proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`} )} {s.yearsExperience != null && ( {s.yearsExperience}y )} ))}
)} {/* Active / Upcoming Allocations */}

Active & Upcoming Allocations

Next 90 days
{loadingAllocations ? (
Loading…
) : (
{canViewCosts && } {upcomingAllocations.map((a) => { const isOver = a.hoursPerDay > 8; return ( {canViewCosts && ( )} ); })} {upcomingAllocations.length === 0 && ( )}
Project Role Period h/DayDaily CostStatus
{a.project ? ( <> {a.project.shortCode} {a.project.name} ) : ( )} {a.role ?? (a.roleEntity?.name ?? "—")} {formatDate(a.startDate)} → {formatDate(a.endDate)} {a.hoursPerDay}h {a.dailyCostCents > 0 ? `${formatMoney(a.dailyCostCents)}/d` : "—"} {a.status}
No active or upcoming allocations.
)}
{/* Vacations */}

Vacations

Last month + upcoming
{loadingVacations ? (
Loading…
) : (vacations ?? []).length === 0 ? (
No vacations recorded.
) : (
{(vacations ?? []).map((v) => { const days = Math.round( (new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) / (1000 * 60 * 60 * 24), ) + 1; return (
{v.type.replace(/_/g, " ")}
{formatDate(v.startDate)} → {formatDate(v.endDate)} ({days} day{days !== 1 ? "s" : ""})
{v.note && (
{v.note}
)}
{v.status}
); })}
)}
{/* Edit modal */} {editOpen && ( { setEditOpen(false); await utils.resource.getById.invalidate({ id: resourceId }); }} /> )} {/* Skill Matrix Upload modal */} {uploadOpen && ( setUploadOpen(false)} onSuccess={async () => { setUploadOpen(false); await utils.resource.getById.invalidate({ id: resourceId }); }} /> )}
); }