cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
661 lines
28 KiB
TypeScript
661 lines
28 KiB
TypeScript
"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: () => <div className="h-64 shimmer-skeleton rounded-xl" /> },
|
|
);
|
|
|
|
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<number, string> = {
|
|
1: "Beginner",
|
|
2: "Elementary",
|
|
3: "Intermediate",
|
|
4: "Advanced",
|
|
5: "Expert",
|
|
};
|
|
|
|
const proficiencyColor: Record<number, string> = {
|
|
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<string, string> = {
|
|
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<string, string> = {
|
|
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 (
|
|
<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>
|
|
{ring ? (
|
|
<div className="flex items-center gap-3">
|
|
<ProgressRing value={ring.value} size={48} strokeWidth={3.5} color={ring.color}>
|
|
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{value}</span>
|
|
</ProgressRing>
|
|
</div>
|
|
) : (
|
|
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
|
|
)}
|
|
{sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<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 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 (
|
|
<div className="p-6">
|
|
<div className="space-y-4">
|
|
<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` }} />)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !resource) {
|
|
return (
|
|
<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>
|
|
</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 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<string>();
|
|
|
|
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 (
|
|
<div className="p-6 space-y-6">
|
|
{/* Back navigation */}
|
|
<Link
|
|
href="/resources"
|
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Back to Resources
|
|
</Link>
|
|
|
|
{/* Header */}
|
|
<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>
|
|
<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 ? "Active" : "Inactive"}
|
|
</span>
|
|
</div>
|
|
<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>
|
|
{resource.chapter && (
|
|
<>
|
|
{" · "}
|
|
<span>{resource.chapter}</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{canUpload && (
|
|
<button
|
|
type="button"
|
|
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>
|
|
Update Skill Matrix
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
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>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats cards */}
|
|
{canViewCosts && (
|
|
<div className="flex justify-end">
|
|
<label className="flex items-center gap-2 text-sm text-gray-600">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeProposedChargeability}
|
|
onChange={(event) => setIncludeProposedChargeability(event.target.checked)}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
Include proposed in chargeability
|
|
</label>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{canViewCosts && (
|
|
<StatCard
|
|
label="LCR"
|
|
value={`${formatMoney(resource.lcrCents, resource.currency)}/h`}
|
|
tooltip="Loaded Cost Rate: fully-loaded hourly cost including salary, benefits, and overhead. Used in budget calculations."
|
|
/>
|
|
)}
|
|
{canViewCosts && (
|
|
<StatCard
|
|
label="UCR"
|
|
value={`${formatMoney(resource.ucrCents, resource.currency)}/h`}
|
|
tooltip="Unit Cost Rate: the rate charged to the client or project for this resource's time."
|
|
/>
|
|
)}
|
|
<StatCard
|
|
label="Chargeability Target"
|
|
value={`${resource.chargeabilityTarget}%`}
|
|
tooltip="The percentage of working time this resource is expected to spend on chargeable/billable work."
|
|
ring={{
|
|
value: resource.chargeabilityTarget,
|
|
color: "var(--color-blue-500, #3b82f6)",
|
|
}}
|
|
/>
|
|
{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)",
|
|
},
|
|
} : {})}
|
|
/>
|
|
)}
|
|
{canViewCosts && (
|
|
<StatCard
|
|
label="Expected (this month)"
|
|
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
|
|
tooltip="Expected chargeability based on all non-cancelled bookings for the current month."
|
|
sub="All non-cancelled bookings"
|
|
/>
|
|
)}
|
|
<StatCard
|
|
label="Hours This Month"
|
|
value={`${Math.round(totalHoursThisMonth)}h`}
|
|
tooltip="Sum of allocated hours for all active projects in the current calendar month."
|
|
sub={`${activeProjectIds.size} active project${activeProjectIds.size !== 1 ? "s" : ""}`}
|
|
/>
|
|
{canViewScores && resourceWithMeta.valueScore != null && (
|
|
<div className="relative group">
|
|
<StatCard
|
|
label="Value Score"
|
|
value={resourceWithMeta.valueScore}
|
|
tooltip="Composite score (0-100) combining skill depth, breadth, cost efficiency, chargeability, and experience. Hover for breakdown."
|
|
sub="Price/Quality"
|
|
/>
|
|
{resourceWithMeta.valueScoreBreakdown && (
|
|
<div className="absolute left-0 top-full mt-1 z-10 hidden group-hover:block w-56 bg-white rounded-xl border border-gray-200 shadow-lg p-3 text-xs space-y-1.5">
|
|
<p className="font-semibold text-gray-700 mb-2">Score Breakdown</p>
|
|
{(
|
|
[
|
|
["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]) => (
|
|
<div key={label}>
|
|
<div className="flex justify-between text-gray-600 mb-0.5">
|
|
<span>{label}</span>
|
|
<span className="font-mono">{val}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${val >= 70 ? "bg-green-500" : val >= 40 ? "bg-amber-400" : "bg-red-400"}`}
|
|
style={{ width: `${val}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Profile meta (area role, portfolio, last import) */}
|
|
{(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>
|
|
</div>
|
|
)}
|
|
{resourceWithMeta.portfolioUrl && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-gray-500 text-xs">Portfolio:</span>
|
|
<a
|
|
href={resourceWithMeta.portfolioUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-brand-600 hover:underline truncate max-w-xs"
|
|
>
|
|
{resourceWithMeta.portfolioUrl}
|
|
</a>
|
|
</div>
|
|
)}
|
|
{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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Summary */}
|
|
<AiSummaryCard
|
|
resourceId={resourceId}
|
|
aiSummary={resourceWithMeta.aiSummary ?? null}
|
|
aiSummaryUpdatedAt={resourceWithMeta.aiSummaryUpdatedAt ?? null}
|
|
onGenerated={async () => { await utils.resource.getById.invalidate({ id: resourceId }); }}
|
|
/>
|
|
|
|
{/* 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>
|
|
<div className="flex flex-wrap gap-2">
|
|
{mainSkills.map((s) => (
|
|
<span
|
|
key={s.skill}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-700"
|
|
>
|
|
<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"}`}>
|
|
{proficiencyLabel[s.proficiency] ?? `L${s.proficiency}`}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Skill Radar Chart */}
|
|
<FadeIn delay={0.1} direction="up">
|
|
<SkillRadarChart skills={skills} />
|
|
</FadeIn>
|
|
|
|
{/* 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>
|
|
<div className="flex flex-wrap gap-2">
|
|
{resourceRoles.map((rr) => (
|
|
<span
|
|
key={rr.role.id}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full"
|
|
style={{
|
|
backgroundColor: `${rr.role.color ?? "#6366f1"}22`,
|
|
color: rr.role.color ?? "#6366f1",
|
|
}}
|
|
>
|
|
{rr.isPrimary && <span className="text-[11px]">★</span>}
|
|
{rr.role.name}
|
|
{rr.isPrimary && <span className="text-[10px] opacity-70">Primary</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
<div className="flex flex-wrap gap-2">
|
|
{skills.map((s) => (
|
|
<span
|
|
key={s.skill}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
|
|
>
|
|
{s.skill}
|
|
{s.proficiency != null && (
|
|
<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>
|
|
)}
|
|
{s.yearsExperience != null && (
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">{s.yearsExperience}y</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Active / Upcoming Allocations */}
|
|
<div className="bg-white rounded-xl border border-gray-200">
|
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-gray-800">Active & Upcoming Allocations</h2>
|
|
<span className="text-xs text-gray-400">Next 90 days</span>
|
|
</div>
|
|
{loadingAllocations ? (
|
|
<div className="p-6 text-center text-gray-400 text-sm animate-pulse">Loading…</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Project</th>
|
|
<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>}
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{upcomingAllocations.map((a) => {
|
|
const isOver = a.hoursPerDay > 8;
|
|
return (
|
|
<tr key={a.id} className={`hover:bg-gray-50 ${isOver ? "bg-amber-50" : ""}`}>
|
|
<td className="px-4 py-3">
|
|
{a.project ? (
|
|
<>
|
|
<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-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"}`}>
|
|
{a.hoursPerDay}h
|
|
</td>
|
|
{canViewCosts && (
|
|
<td className="px-4 py-3 text-right text-gray-700">
|
|
{a.dailyCostCents > 0
|
|
? `${formatMoney(a.dailyCostCents)}/d`
|
|
: "—"}
|
|
</td>
|
|
)}
|
|
<td className="px-4 py-3 text-right">
|
|
<span
|
|
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
|
allocationStatusColor[a.status] ?? "bg-gray-100 text-gray-600"
|
|
}`}
|
|
>
|
|
{a.status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{upcomingAllocations.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="text-center py-8 text-gray-400 text-sm">
|
|
No active or upcoming allocations.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Vacations */}
|
|
<div className="bg-white rounded-xl border border-gray-200">
|
|
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-gray-800">Vacations</h2>
|
|
<span className="text-xs text-gray-400">Last month + upcoming</span>
|
|
</div>
|
|
{loadingVacations ? (
|
|
<div className="p-6 text-center text-gray-400 text-sm animate-pulse">Loading…</div>
|
|
) : (vacations ?? []).length === 0 ? (
|
|
<div className="text-center py-8 text-gray-400 text-sm">No vacations recorded.</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{(vacations ?? []).map((v) => {
|
|
const days =
|
|
Math.round(
|
|
(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">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-gray-800">
|
|
{v.type.replace(/_/g, " ")}
|
|
</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>
|
|
</div>
|
|
{v.note && (
|
|
<div className="text-xs text-gray-400 mt-0.5 italic truncate max-w-sm">{v.note}</div>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
|
|
vacationStatusColor[v.status] ?? "bg-gray-100 text-gray-600"
|
|
}`}
|
|
>
|
|
{v.status}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit modal */}
|
|
{editOpen && (
|
|
<ResourceModal
|
|
mode="edit"
|
|
resource={resource as unknown as Resource}
|
|
onClose={async () => {
|
|
setEditOpen(false);
|
|
await utils.resource.getById.invalidate({ id: resourceId });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Skill Matrix Upload modal */}
|
|
{uploadOpen && (
|
|
<SkillMatrixUpload
|
|
resourceId={resourceId}
|
|
isOwner={isOwner}
|
|
onClose={() => setUploadOpen(false)}
|
|
onSuccess={async () => {
|
|
setUploadOpen(false);
|
|
await utils.resource.getById.invalidate({ id: resourceId });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|