chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDate } from "~/lib/format.js";
interface Props {
resourceId: string;
aiSummary: string | null;
aiSummaryUpdatedAt: Date | string | null;
onGenerated: () => void;
}
export function AiSummaryCard({ resourceId, aiSummary, aiSummaryUpdatedAt, onGenerated }: Props) {
const [localSummary, setLocalSummary] = useState<string | null>(aiSummary);
const [localUpdatedAt, setLocalUpdatedAt] = useState<Date | string | null>(aiSummaryUpdatedAt);
const [error, setError] = useState<string | null>(null);
const { canEdit } = usePermissions();
// Keep local state in sync if the parent refreshes with newer data
if (aiSummary && aiSummary !== localSummary) {
setLocalSummary(aiSummary);
setLocalUpdatedAt(aiSummaryUpdatedAt);
}
const generateMutation = trpc.resource.generateAiSummary.useMutation({
onSuccess: (data) => {
setError(null);
setLocalSummary(data.summary);
setLocalUpdatedAt(new Date());
onGenerated();
},
onError: (err) => {
setError(err.message ?? "Failed to generate summary");
},
});
const { data: aiConfigured } = trpc.settings.getAiConfigured.useQuery(undefined, {
staleTime: 5_000,
});
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-800">AI Profile Summary</h2>
{canEdit && aiConfigured?.configured && (
<button
type="button"
onClick={() => generateMutation.mutate({ resourceId })}
disabled={generateMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50 transition-colors"
>
{generateMutation.isPending ? (
<>
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Generating
</>
) : localSummary ? (
"Regenerate"
) : (
"Generate"
)}
</button>
)}
</div>
{error && (
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-700">
{error}
</div>
)}
{localSummary ? (
<>
<p className="text-sm text-gray-700 leading-relaxed">{localSummary}</p>
{localUpdatedAt && (
<p className="text-xs text-gray-400 mt-2">
Generated {formatDate(localUpdatedAt)}
</p>
)}
</>
) : (
<p className="text-sm text-gray-400 italic">
{!aiConfigured?.configured
? "AI not configured. Set up your API key in Admin → Settings."
: canEdit
? "No summary yet. Click Generate to create one."
: "No summary generated yet."}
</p>
)}
</div>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useState } from "react";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
interface Props {
selectedIds: string[];
fieldDefs: BlueprintFieldDefinition[];
onClose: () => void;
onSuccess: () => void;
}
export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Props) {
// Track which fields are included in the bulk update + their new values
const [included, setIncluded] = useState<Set<string>>(new Set());
const [values, setValues] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onSuccess();
onClose();
},
onError: (err) => setError(err.message),
});
function toggleInclude(key: string) {
setIncluded((prev) => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next;
});
}
function setValue(key: string, value: unknown) {
setValues((prev) => ({ ...prev, [key]: value }));
}
function handleSave() {
setError(null);
const fields: Record<string, unknown> = {};
for (const key of included) {
fields[key] = values[key] ?? "";
}
if (Object.keys(fields).length === 0) {
setError("Select at least one field to update.");
return;
}
mutation.mutate({ ids: selectedIds, fields });
}
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
const editableFields = fieldDefs.filter((f) => !f.required || included.has(f.key));
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={handleBackdrop}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div>
<h2 className="text-lg font-semibold text-gray-900">Bulk Edit Custom Fields</h2>
<p className="text-sm text-gray-500 mt-0.5">
Updating {selectedIds.length} resource{selectedIds.length !== 1 ? "s" : ""}
</p>
</div>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
</div>
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fieldDefs.length === 0 && (
<p className="text-sm text-gray-400 text-center py-6">No custom fields defined. Configure them in Admin Blueprints.</p>
)}
{fieldDefs.map((field) => (
<div key={field.key} className={`border rounded-lg p-3 transition-colors ${included.has(field.key) ? "border-brand-300 bg-brand-50" : "border-gray-200"}`}>
<label className="flex items-center gap-2 mb-2 cursor-pointer">
<input
type="checkbox"
checked={included.has(field.key)}
onChange={() => toggleInclude(field.key)}
className="rounded border-gray-300 text-brand-600"
/>
<span className="text-sm font-medium text-gray-700">{field.label}</span>
{field.required && <span className="text-xs text-red-500">required</span>}
</label>
{included.has(field.key) && (
<FieldInput
field={field}
value={values[field.key]}
onChange={(v) => setValue(field.key, v)}
/>
)}
</div>
))}
</div>
{error && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<p className="text-xs text-gray-400">{included.size} field{included.size !== 1 ? "s" : ""} selected</p>
<div className="flex gap-3">
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium">
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={mutation.isPending || included.size === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{mutation.isPending ? "Saving…" : `Apply to ${selectedIds.length} resource${selectedIds.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
</div>
</div>
);
}
function FieldInput({ field, value, onChange }: { field: BlueprintFieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
const str = value !== undefined && value !== null ? String(value) : "";
if (field.type === FieldType.BOOLEAN) {
return (
<select value={str} onChange={(e) => onChange(e.target.value === "true")} className={INPUT_CLS}>
<option value=""> select </option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (field.type === FieldType.SELECT && field.options) {
return (
<select value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS}>
<option value=""> select </option>
{field.options.map((o) => <option key={o.value} value={o.value}>{o.label || o.value}</option>)}
</select>
);
}
if (field.type === FieldType.NUMBER) {
return (
<input
type="number"
value={str}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
placeholder={field.placeholder}
className={INPUT_CLS}
/>
);
}
if (field.type === FieldType.DATE) {
return <input type="date" value={str} onChange={(e) => onChange(e.target.value)} className={INPUT_CLS} />;
}
if (field.type === FieldType.TEXTAREA) {
return (
<textarea
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className={`${INPUT_CLS} w-full resize-none`}
rows={3}
/>
);
}
return (
<input
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"}
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className={`${INPUT_CLS} w-full`}
/>
);
}
// Re-export for convenience
export type { Props as BulkEditModalProps };
@@ -0,0 +1,312 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSpreadsheet, isSpreadsheetFile } from "~/lib/excel.js";
type ImportStage = "idle" | "preview" | "importing" | "done";
interface ImportResult {
total: number;
created: number;
updated: number;
errors: { row: number; message: string }[];
dryRun: boolean;
message?: string;
}
interface Props {
onClose: () => void;
}
export function ImportModal({ onClose }: Props) {
const [stage, setStage] = useState<ImportStage>("idle");
const [rows, setRows] = useState<Record<string, string>[]>([]);
const [fileName, setFileName] = useState<string>("");
const [fileError, setFileError] = useState<string>("");
const [result, setResult] = useState<ImportResult | null>(null);
const [dryRun, setDryRun] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const importMutation = trpc.importExport.importCSV.useMutation({
onSuccess: (data) => {
setResult(data);
setStage("done");
},
onError: (err) => {
setFileError(err.message);
setStage("preview");
},
});
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setFileError("");
setRows([]);
setResult(null);
if (!isSpreadsheetFile(file)) {
setFileError("Unsupported file type. Please upload an Excel (.xlsx, .xls) or CSV file.");
return;
}
setFileName(file.name);
try {
const parsed = await parseSpreadsheet(file);
setRows(parsed);
setStage("preview");
} catch (err) {
setFileError(err instanceof Error ? err.message : "Failed to parse file.");
}
}
function handleImport() {
if (rows.length === 0) return;
setStage("importing");
importMutation.mutate({
entityType: "resources",
rows,
dryRun,
});
}
function handleReset() {
setStage("idle");
setRows([]);
setFileName("");
setFileError("");
setResult(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
const previewHeaders = rows.length > 0 ? Object.keys(rows[0]!) : [];
const previewRows = rows.slice(0, 5);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Import Resources</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* File picker */}
{stage === "idle" && (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Upload an Excel or CSV file to import resources. The first row must contain column headers
matching the resource fields (e.g.{" "}
<code className="px-1 py-0.5 bg-gray-100 rounded text-xs font-mono">
eid, displayName, email, chapter, lcrCents
</code>
).
</p>
<label className="block">
<span className="sr-only">Choose spreadsheet file</span>
<div
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-brand-400 hover:bg-brand-50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm text-gray-500">Click to select Excel or CSV</p>
<p className="text-xs text-gray-400 mt-1">.xlsx, .xls, .csv supported</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={handleFileChange}
/>
</label>
{fileError && (
<p className="text-sm text-red-600">{fileError}</p>
)}
</div>
)}
{/* Preview */}
{(stage === "preview" || stage === "importing") && rows.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{fileName}</p>
<p className="text-xs text-gray-500">{rows.length} row{rows.length !== 1 ? "s" : ""} parsed</p>
</div>
<button
type="button"
onClick={handleReset}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Choose different file
</button>
</div>
{previewHeaders.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Preview (first {previewRows.length} of {rows.length} rows)
</p>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="text-xs w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{previewHeaders.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{previewRows.map((row, i) => (
<tr key={i} className="hover:bg-gray-50">
{previewHeaders.map((h) => (
<td key={h} className="px-3 py-2 text-gray-700 whitespace-nowrap max-w-[200px] truncate">
{row[h] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length > 5 && (
<p className="text-xs text-gray-400 mt-1">and {rows.length - 5} more rows</p>
)}
</div>
)}
{fileError && (
<p className="text-sm text-red-600">{fileError}</p>
)}
{/* Import options */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dryRun"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="dryRun" className="text-sm text-gray-700">
Dry run (validate only, do not write to database)
</label>
</div>
</div>
)}
{/* Done */}
{stage === "done" && result && (
<div className="space-y-4">
<div className={`rounded-lg p-4 ${result.errors.length > 0 ? "bg-yellow-50 border border-yellow-200" : "bg-green-50 border border-green-200"}`}>
<p className="text-sm font-medium text-gray-900 mb-1">
{result.dryRun ? "Dry run complete" : "Import complete"}
</p>
<ul className="text-sm text-gray-700 space-y-0.5">
<li>Total rows: <strong>{result.total}</strong></li>
{!result.dryRun && (
<>
<li>Created: <strong>{result.created}</strong></li>
<li>Updated: <strong>{result.updated}</strong></li>
</>
)}
{result.message && <li>{result.message}</li>}
{result.errors.length > 0 && (
<li className="text-red-600">Errors: <strong>{result.errors.length}</strong></li>
)}
</ul>
</div>
{result.errors.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Errors</p>
<ul className="text-xs text-red-600 space-y-0.5 max-h-32 overflow-y-auto">
{result.errors.map((e, i) => (
<li key={i}>Row {e.row}: {e.message}</li>
))}
</ul>
</div>
)}
{result.dryRun && result.errors.length === 0 && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dryRunConfirm"
checked={false}
onChange={() => {
setDryRun(false);
setStage("preview");
setResult(null);
}}
className="rounded border-gray-300"
/>
<label htmlFor="dryRunConfirm" className="text-sm text-gray-700 cursor-pointer">
Validation passed click here to run the actual import
</label>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Close
</button>
{(stage === "preview") && (
<button
type="button"
onClick={handleImport}
disabled={rows.length === 0 || importMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 transition-colors"
>
{dryRun ? "Validate" : "Import"} {rows.length} row{rows.length !== 1 ? "s" : ""}
</button>
)}
{stage === "importing" && (
<span className="px-4 py-2 text-sm text-gray-500 animate-pulse">Importing</span>
)}
{stage === "done" && (
<button
type="button"
onClick={handleReset}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Import another file
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,598 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js";
import { SkillRadarChart } from "./SkillRadarChart.js";
import { AiSummaryCard } from "./AiSummaryCard.js";
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
import { usePermissions } from "~/hooks/usePermissions.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",
2: "bg-blue-50 text-blue-600",
3: "bg-brand-50 text-brand-700",
4: "bg-amber-50 text-amber-700",
5: "bg-green-50 text-green-700",
};
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 }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="text-xl font-bold text-gray-900">{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 [uploadOpen, setUploadOpen] = useState(false);
const utils = trpc.useUtils();
const { canViewCosts, canEdit, canViewScores } = usePermissions();
const { data: session } = useSession();
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(
{ 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="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-64" />
<div className="h-4 bg-gray-100 rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[0, 1, 2, 3].map((i) => <div key={i} className="h-20 bg-gray-100 rounded-xl" />)}
</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;
};
const currentUserEmail = session?.user?.email;
const isOwner = !!(resourceWithMeta.userId && currentUserEmail &&
(resource as unknown as { user?: { email?: string } }).user?.email === currentUserEmail);
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 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" : "bg-red-100 text-red-700"
}`}
>
{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 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{canViewCosts && (
<StatCard
label="LCR"
value={`${(resource.lcrCents / 100).toFixed(0)} ${resource.currency}/h`}
/>
)}
{canViewCosts && (
<StatCard
label="UCR"
value={`${(resource.ucrCents / 100).toFixed(0)} ${resource.currency}/h`}
/>
)}
<StatCard
label="Chargeability Target"
value={`${resource.chargeabilityTarget}%`}
/>
{canViewCosts && (
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
sub="Excl. draft projects"
/>
)}
{canViewCosts && (
<StatCard
label="Expected (this month)"
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
sub="Incl. draft projects"
/>
)}
<StatCard
label="Hours This Month"
value={`${Math.round(totalHoursThisMonth)}h`}
sub={`${activeProjectIds.size} active project${activeProjectIds.size !== 1 ? "s" : ""}`}
/>
{canViewScores && resourceWithMeta.valueScore != null && (
<div className="relative group">
<StatCard
label="Value Score"
value={resourceWithMeta.valueScore}
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 rounded-xl border border-gray-200 p-4 flex flex-wrap gap-4 text-sm">
{resourceWithMeta.areaRole && (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Area:</span>
<span className="font-medium text-gray-800">{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 rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Main Skills</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"
>
<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 */}
<SkillRadarChart skills={skills} />
{/* Roles */}
{resourceRoles.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Roles</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 rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">Skills</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"
>
{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">{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 &amp; 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
? `${(a.dailyCostCents / 100).toFixed(0)}/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>
);
}
@@ -0,0 +1,941 @@
"use client";
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry } from "@planarchy/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
interface RoleAssignment {
roleId: string;
isPrimary: boolean;
}
interface SkillRow {
skill: string;
proficiency: 1 | 2 | 3 | 4 | 5;
yearsExperience: string;
category: string;
certified: boolean;
isMainSkill: boolean;
}
interface FormState {
eid: string;
displayName: string;
email: string;
chapter: string;
lcrEuros: string;
ucrEuros: string;
currency: string;
chargeabilityTarget: string;
monday: string;
tuesday: string;
wednesday: string;
thursday: string;
friday: string;
skills: SkillRow[];
roles: RoleAssignment[];
portfolioUrl: string;
roleId: string;
postalCode: string;
federalState: string;
countryId: string;
metroCityId: string;
orgUnitId: string;
managementLevelGroupId: string;
managementLevelId: string;
resourceType: string;
chgResponsibility: boolean;
enterpriseId: string;
clientUnitId: string;
fte: string;
}
function resourceToFormState(resource: Resource): FormState {
const skills = (resource.skills as SkillEntry[]).map((s) => ({
skill: s.skill,
proficiency: s.proficiency,
yearsExperience: s.yearsExperience != null ? String(s.yearsExperience) : "",
category: s.category ?? "",
certified: s.certified ?? false,
isMainSkill: s.isMainSkill ?? false,
}));
const roles: RoleAssignment[] = (resource.roles ?? []).map((r) => ({
roleId: r.roleId,
isPrimary: r.isPrimary,
}));
const resourceWithMeta = resource as unknown as {
portfolioUrl?: string | null;
roleId?: string | null;
};
return {
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter ?? "",
lcrEuros: String(resource.lcrCents / 100),
ucrEuros: String(resource.ucrCents / 100),
currency: resource.currency,
chargeabilityTarget: String(resource.chargeabilityTarget),
monday: String(resource.availability.monday),
tuesday: String(resource.availability.tuesday),
wednesday: String(resource.availability.wednesday),
thursday: String(resource.availability.thursday),
friday: String(resource.availability.friday),
skills,
roles,
portfolioUrl: resourceWithMeta.portfolioUrl ?? "",
roleId: resourceWithMeta.roleId ?? "",
postalCode: (resource as unknown as { postalCode?: string | null }).postalCode ?? "",
federalState: (resource as unknown as { federalState?: string | null }).federalState ?? "",
countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "",
metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "",
orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "",
managementLevelGroupId: (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ?? "",
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
clientUnitId: (resource as unknown as { clientUnitId?: string | null }).clientUnitId ?? "",
fte: String((resource as unknown as { fte?: number }).fte ?? 1),
};
}
function defaultFormState(): FormState {
return {
eid: "",
displayName: "",
email: "",
chapter: "",
lcrEuros: "",
ucrEuros: "",
currency: "EUR",
chargeabilityTarget: "80",
monday: "8",
tuesday: "8",
wednesday: "8",
thursday: "8",
friday: "8",
skills: [],
roles: [],
portfolioUrl: "",
roleId: "",
postalCode: "",
federalState: "",
countryId: "",
metroCityId: "",
orgUnitId: "",
managementLevelGroupId: "",
managementLevelId: "",
resourceType: "EMPLOYEE",
chgResponsibility: true,
enterpriseId: "",
clientUnitId: "",
fte: "1",
};
}
function defaultSkillRow(): SkillRow {
return { skill: "", proficiency: 3, yearsExperience: "", category: "", certified: false, isMainSkill: false };
}
interface ResourceModalProps {
mode: "create" | "edit";
resource?: Resource;
onClose: () => void;
}
const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const SECTION_HEADER_CLASS = "text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4";
const PRIMARY_BTN =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
function Spinner() {
return (
<svg
className="animate-spin h-4 w-4 text-white inline-block mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
const [form, setForm] = useState<FormState>(() =>
resource ? resourceToFormState(resource) : defaultFormState(),
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const { data: availableRoles } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 });
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 });
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// Derive metro cities from selected country
const selectedCountry = (countries ?? []).find((c) => c.id === form.countryId) as unknown as { id: string; metroCities: { id: string; name: string }[] } | undefined;
const metroCities = selectedCountry?.metroCities ?? [];
// Derive levels from selected group
const selectedGroup = (mgmtGroups ?? []).find((g) => g.id === form.managementLevelGroupId) as unknown as { id: string; levels: { id: string; name: string }[] } | undefined;
const mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onClose();
},
onError: (err) => {
setErrorMsg(err.message ?? "An error occurred while saving.");
},
});
const updateMutation = trpc.resource.update.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
onClose();
},
onError: (err) => {
setErrorMsg(err.message ?? "An error occurred while saving.");
},
});
const isMutating = createMutation.isPending || updateMutation.isPending;
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
setErrorMsg(null);
}
function setSkillField(index: number, key: keyof SkillRow, value: string | number | boolean) {
setForm((prev) => {
const skills = prev.skills.map((s, i) => (i === index ? { ...s, [key]: value } : s));
return { ...prev, skills };
});
}
function addSkill() {
setForm((prev) => ({ ...prev, skills: [...prev.skills, defaultSkillRow()] }));
}
function removeSkill(index: number) {
setForm((prev) => ({ ...prev, skills: prev.skills.filter((_, i) => i !== index) }));
}
function buildPayload() {
const lcrCents = Math.round(parseFloat(form.lcrEuros || "0") * 100);
const ucrCents = Math.round(parseFloat(form.ucrEuros || "0") * 100);
const chargeabilityTarget = parseFloat(form.chargeabilityTarget || "80");
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
const skills = form.skills
.filter((s) => s.skill.trim() !== "")
.map((s) => ({
skill: s.skill.trim(),
proficiency: s.proficiency,
...(s.yearsExperience !== "" ? { yearsExperience: parseFloat(s.yearsExperience) } : {}),
...(s.category.trim() !== "" ? { category: s.category.trim() } : {}),
...(s.certified ? { certified: s.certified } : {}),
...(s.isMainSkill ? { isMainSkill: true } : {}),
}));
void mainSkillCount; // used for UI validation only
return {
eid: form.eid.trim(),
displayName: form.displayName.trim(),
email: form.email.trim(),
...(form.chapter.trim() !== "" ? { chapter: form.chapter.trim() } : {}),
lcrCents,
ucrCents,
currency: form.currency,
chargeabilityTarget,
availability: {
monday: parseFloat(form.monday || "8"),
tuesday: parseFloat(form.tuesday || "8"),
wednesday: parseFloat(form.wednesday || "8"),
thursday: parseFloat(form.thursday || "8"),
friday: parseFloat(form.friday || "8"),
},
skills,
roles: form.roles,
...(form.portfolioUrl.trim() !== "" ? { portfolioUrl: form.portfolioUrl.trim() } : {}),
...(form.roleId.trim() !== "" ? { roleId: form.roleId.trim() } : {}),
...(form.postalCode.trim() !== "" ? { postalCode: form.postalCode.trim() } : {}),
...(form.federalState.trim() !== "" ? { federalState: form.federalState.trim() } : {}),
...(form.countryId ? { countryId: form.countryId } : {}),
...(form.metroCityId ? { metroCityId: form.metroCityId } : {}),
...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}),
...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}),
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility,
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
fte: parseFloat(form.fte) || 1,
};
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrorMsg(null);
const payload = buildPayload();
if (mode === "create") {
createMutation.mutate(payload);
} else if (resource) {
updateMutation.mutate({ id: resource.id, data: payload });
}
}
const proficiencyLabels: Record<number, string> = {
1: "1 Beginner",
2: "2 Elementary",
3: "3 Intermediate",
4: "4 Advanced",
5: "5 Expert",
};
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{mode === "create" ? "New Resource" : "Edit Resource"}
</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close modal"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} noValidate>
<div className="px-6 pb-4">
{/* Section 1: Basic Info */}
<p className={SECTION_HEADER_CLASS}>Basic Info</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-eid">
Employee ID <span className="text-red-500">*</span>
</label>
<input
id="rm-eid"
type="text"
className={INPUT_CLASS}
placeholder="EMP-042"
value={form.eid}
onChange={(e) => setField("eid", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-displayName">
Display Name <span className="text-red-500">*</span>
</label>
<input
id="rm-displayName"
type="text"
className={INPUT_CLASS}
placeholder="Jane Smith"
value={form.displayName}
onChange={(e) => setField("displayName", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-email">
Email <span className="text-red-500">*</span>
</label>
<input
id="rm-email"
type="email"
className={INPUT_CLASS}
placeholder="jane@example.com"
value={form.email}
onChange={(e) => setField("email", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chapter">
Chapter <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-chapter"
type="text"
className={INPUT_CLASS}
placeholder="Engineering"
value={form.chapter}
onChange={(e) => setField("chapter", e.target.value)}
/>
</div>
</div>
{/* Portfolio & Role */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-portfolioUrl">
Portfolio URL <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-portfolioUrl"
type="url"
className={INPUT_CLASS}
placeholder="https://artstation.com/…"
value={form.portfolioUrl}
onChange={(e) => setField("portfolioUrl", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-roleId">
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<select
id="rm-roleId"
className={INPUT_CLASS}
value={form.roleId}
onChange={(e) => setField("roleId", e.target.value)}
>
<option value=""> Not specified </option>
{(availableRoles ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
</div>
{/* Postal Code & Federal State */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-postalCode">
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-postalCode"
type="text"
className={INPUT_CLASS}
placeholder="80331"
maxLength={5}
value={form.postalCode}
onChange={(e) => {
const plz = e.target.value;
setField("postalCode", plz);
if (/^\d{5}$/.test(plz)) {
const inferred = inferStateFromPostalCode(plz);
if (inferred && !form.federalState) {
setField("federalState", inferred);
}
}
}}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-federalState">
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<select
id="rm-federalState"
className={INPUT_CLASS}
value={form.federalState}
onChange={(e) => setField("federalState", e.target.value)}
>
<option value=""> Not specified </option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
))}
</select>
</div>
</div>
{/* Section: Organization & Classification */}
<p className={SECTION_HEADER_CLASS}>Organization &amp; Classification</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-enterpriseId">
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label>
<input
id="rm-enterpriseId"
type="text"
className={INPUT_CLASS}
placeholder="a.kasperovich"
value={form.enterpriseId}
onChange={(e) => setField("enterpriseId", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-fte">
FTE
</label>
<input
id="rm-fte"
type="number"
min="0.01"
max="1"
step="0.01"
className={INPUT_CLASS}
placeholder="1.0"
value={form.fte}
onChange={(e) => setField("fte", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-countryId">Country</label>
<select
id="rm-countryId"
className={INPUT_CLASS}
value={form.countryId}
onChange={(e) => {
setField("countryId", e.target.value);
setField("metroCityId", ""); // reset city when country changes
}}
>
<option value=""> Not specified </option>
{(countries ?? []).map((c) => (
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-metroCityId">Metro City</label>
<select
id="rm-metroCityId"
className={INPUT_CLASS}
value={form.metroCityId}
onChange={(e) => setField("metroCityId", e.target.value)}
disabled={!form.countryId}
>
<option value=""> Not specified </option>
{metroCities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-orgUnitId">Org Unit (L7 Team)</label>
<select
id="rm-orgUnitId"
className={INPUT_CLASS}
value={form.orgUnitId}
onChange={(e) => setField("orgUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{(orgUnits ?? [])
.filter((u) => (u as unknown as { level: number }).level === 7 && (u as unknown as { isActive: boolean }).isActive)
.map((u) => (
<option key={u.id} value={u.id}>{(u as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-clientUnitId">Client Unit</label>
<select
id="rm-clientUnitId"
className={INPUT_CLASS}
value={form.clientUnitId}
onChange={(e) => setField("clientUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{(clients ?? []).map((c) => (
<option key={c.id} value={c.id}>{(c as unknown as { name: string }).name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group</label>
<select
id="rm-mgmtGroupId"
className={INPUT_CLASS}
value={form.managementLevelGroupId}
onChange={(e) => {
setField("managementLevelGroupId", e.target.value);
setField("managementLevelId", ""); // reset level when group changes
}}
>
<option value=""> Not specified </option>
{(mgmtGroups ?? []).map((g) => (
<option key={g.id} value={g.id}>{(g as unknown as { name: string }).name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level</label>
<select
id="rm-mgmtLevelId"
className={INPUT_CLASS}
value={form.managementLevelId}
onChange={(e) => setField("managementLevelId", e.target.value)}
disabled={!form.managementLevelGroupId}
>
<option value=""> Not specified </option>
{mgmtLevels.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
<select
id="rm-resourceType"
className={INPUT_CLASS}
value={form.resourceType}
onChange={(e) => setField("resourceType", e.target.value)}
>
{Object.values(ResourceType).map((t) => (
<option key={t} value={t}>{t.charAt(0) + t.slice(1).toLowerCase()}</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.chgResponsibility}
onChange={(e) => setField("chgResponsibility", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Chg Responsibility
</label>
</div>
</div>
{/* Section 2: Cost & Chargeability */}
<p className={SECTION_HEADER_CLASS}>Cost &amp; Chargeability</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-lcr">
LCR &euro;/h <span className="text-red-500">*</span>
</label>
<input
id="rm-lcr"
type="number"
min="0"
step="0.01"
className={INPUT_CLASS}
placeholder="80"
value={form.lcrEuros}
onChange={(e) => setField("lcrEuros", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-ucr">
UCR &euro;/h <span className="text-red-500">*</span>
</label>
<input
id="rm-ucr"
type="number"
min="0"
step="0.01"
className={INPUT_CLASS}
placeholder="120"
value={form.ucrEuros}
onChange={(e) => setField("ucrEuros", e.target.value)}
required
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-currency">
Currency
</label>
<select
id="rm-currency"
className={INPUT_CLASS}
value={form.currency}
onChange={(e) => setField("currency", e.target.value)}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-chargeability">
Chargeability Target %
</label>
<input
id="rm-chargeability"
type="number"
min="0"
max="100"
className={INPUT_CLASS}
placeholder="80"
value={form.chargeabilityTarget}
onChange={(e) => setField("chargeabilityTarget", e.target.value)}
/>
</div>
</div>
{/* Section 3: Weekly Availability */}
<p className={SECTION_HEADER_CLASS}>Weekly Availability (hours/day)</p>
<div className="grid grid-cols-5 gap-3">
{(
[
["monday", "Mon"],
["tuesday", "Tue"],
["wednesday", "Wed"],
["thursday", "Thu"],
["friday", "Fri"],
] as const
).map(([day, label]) => (
<div key={day}>
<label className={LABEL_CLASS} htmlFor={`rm-${day}`}>
{label}
</label>
<input
id={`rm-${day}`}
type="number"
min="0"
max="24"
step="0.5"
className={INPUT_CLASS}
value={form[day]}
onChange={(e) => setField(day, e.target.value)}
/>
</div>
))}
</div>
{/* Section 4: Skills */}
<p className={SECTION_HEADER_CLASS}>Skills</p>
<div className="space-y-3">
{form.skills.map((skillRow, idx) => {
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length;
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2;
return (
<div
key={idx}
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`}
>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-name-${idx}`}>
Skill
</label>
<input
id={`rm-skill-name-${idx}`}
type="text"
className={INPUT_CLASS}
placeholder="e.g. 3ds Max"
value={skillRow.skill}
onChange={(e) => setSkillField(idx, "skill", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-prof-${idx}`}>
Proficiency
</label>
<select
id={`rm-skill-prof-${idx}`}
className={INPUT_CLASS}
value={skillRow.proficiency}
onChange={(e) =>
setSkillField(idx, "proficiency", parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5)
}
>
{[1, 2, 3, 4, 5].map((p) => (
<option key={p} value={p}>
{proficiencyLabels[p]}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-years-${idx}`}>
Years
</label>
<input
id={`rm-skill-years-${idx}`}
type="number"
min="0"
max="50"
step="1"
className={INPUT_CLASS}
placeholder="—"
value={skillRow.yearsExperience}
onChange={(e) => setSkillField(idx, "yearsExperience", e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none"> Main</span>
<input
type="checkbox"
checked={skillRow.isMainSkill}
disabled={!canToggleMain}
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
onChange={(e) => setSkillField(idx, "isMainSkill", e.target.checked)}
className="rounded border-gray-300 disabled:opacity-40"
/>
</div>
<div className="flex items-end pb-0.5">
<button
type="button"
onClick={() => removeSkill(idx)}
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
aria-label={`Remove skill ${idx + 1}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={addSkill}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium 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="M12 4v16m8-8H4" />
</svg>
Add skill
</button>
</div>
{/* Section 5: Roles */}
<p className={SECTION_HEADER_CLASS}>Roles</p>
<div className="space-y-2">
{(availableRoles ?? []).map((role) => {
const assignment = form.roles.find((r) => r.roleId === role.id);
const isChecked = Boolean(assignment);
return (
<div key={role.id} className="flex items-center gap-3 py-1">
<input
type="checkbox"
id={`role-${role.id}`}
checked={isChecked}
onChange={(e) => {
if (e.target.checked) {
setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]);
} else {
setField("roles", form.roles.filter((r) => r.roleId !== role.id));
}
}}
className="rounded border-gray-300"
/>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }}
/>
<label htmlFor={`role-${role.id}`} className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1">
{role.name}
</label>
{isChecked && (
<label className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="radio"
name="primary-role"
checked={assignment?.isPrimary ?? false}
onChange={() => {
setField("roles", form.roles.map((r) =>
r.roleId === role.id
? { ...r, isPrimary: true }
: { ...r, isPrimary: false },
));
}}
className="border-gray-300"
/>
Primary
</label>
)}
</div>
);
})}
{(availableRoles ?? []).length === 0 && (
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p>
)}
</div>
{/* Error message */}
{errorMsg && (
<div className="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{errorMsg}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<button
type="button"
onClick={onClose}
disabled={isMutating}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button type="submit" disabled={isMutating} className={PRIMARY_BTN}>
{isMutating && <Spinner />}
{isMutating ? "Saving…" : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,221 @@
"use client";
import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import type { SkillEntry } from "@planarchy/shared";
interface Props {
resourceId: string;
isOwner: boolean; // true = self-service, false = manager import
onClose: () => void;
onSuccess: () => void;
}
type PreviewState = {
skills: SkillEntry[];
employeeInfo: {
roleId?: string;
portfolioUrl?: string;
};
matchedRoleName?: string;
};
export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: Props) {
const [preview, setPreview] = useState<PreviewState | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const selfMutation = trpc.resource.importSkillMatrix.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
});
const managerMutation = trpc.resource.importSkillMatrixForResource.useMutation({
onSuccess: () => { setSubmitting(false); onSuccess(); },
onError: (err) => { setSubmitting(false); setParseError(err.message); },
});
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setParseError(null);
setPreview(null);
try {
const buffer = await file.arrayBuffer();
const parsed = parseSkillMatrixWorkbook(buffer);
// Fuzzy match areaOfExpertise → roleId
let roleId: string | undefined;
let matchedRoleName: string | undefined;
if (parsed.employeeInfo.areaOfExpertise && roles) {
const roleNames = roles.map((r) => r.name);
const matched = matchRoleName(parsed.employeeInfo.areaOfExpertise, roleNames);
if (matched) {
const role = roles.find((r) => r.name === matched);
roleId = role?.id;
matchedRoleName = matched;
}
}
setPreview({
skills: parsed.skills,
employeeInfo: {
...(roleId !== undefined ? { roleId } : {}),
...(parsed.employeeInfo.portfolioUrl !== undefined ? { portfolioUrl: parsed.employeeInfo.portfolioUrl } : {}),
},
...(matchedRoleName !== undefined ? { matchedRoleName } : {}),
});
} catch (err) {
setParseError(String(err instanceof Error ? err.message : err));
}
}
function handleConfirm() {
if (!preview) return;
setSubmitting(true);
const payload = {
skills: preview.skills,
employeeInfo: {
roleId: preview.employeeInfo.roleId,
portfolioUrl: preview.employeeInfo.portfolioUrl,
},
};
if (isOwner) {
selfMutation.mutate(payload);
} else {
managerMutation.mutate({ resourceId, ...payload });
}
}
const mainSkills = preview?.skills.filter((s) => s.isMainSkill) ?? [];
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-900">Update Skill Matrix</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-5 space-y-4">
{/* File picker */}
{!preview && (
<div
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors"
onClick={() => fileRef.current?.click()}
>
<svg className="w-10 h-10 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<p className="text-sm font-medium text-gray-700">Click to select skill matrix file</p>
<p className="text-xs text-gray-400 mt-1">.xlsx accepted</p>
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleFile}
/>
</div>
)}
{parseError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{parseError}
</div>
)}
{/* Preview */}
{preview && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="bg-brand-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-brand-700">{preview.skills.length}</div>
<div className="text-xs text-brand-600 mt-0.5">Skills found</div>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-700">{mainSkills.length}</div>
<div className="text-xs text-green-600 mt-0.5">Main skills</div>
</div>
</div>
{mainSkills.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1.5">Main skills:</p>
<div className="flex flex-wrap gap-1.5">
{mainSkills.map((s) => (
<span key={s.skill} className="px-2.5 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 border border-amber-200">
{s.skill}
</span>
))}
</div>
</div>
)}
{preview.matchedRoleName && (
<p className="text-xs text-gray-600">
<span className="font-medium">Area of expertise</span> matched to Planarchy role:{" "}
<span className="font-semibold text-brand-700">{preview.matchedRoleName}</span>
</p>
)}
{preview.employeeInfo.portfolioUrl && (
<p className="text-xs text-gray-600 truncate">
<span className="font-medium">Portfolio URL:</span>{" "}
<a href={preview.employeeInfo.portfolioUrl} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
{preview.employeeInfo.portfolioUrl}
</a>
</p>
)}
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
This will <strong>replace all existing skills</strong> for this resource.
</p>
<button
type="button"
onClick={() => { setPreview(null); setParseError(null); if (fileRef.current) fileRef.current.value = ""; }}
className="text-xs text-gray-400 hover:text-gray-600 underline"
>
Choose a different file
</button>
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-xl">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={!preview || submitting}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{submitting ? "Importing…" : "Confirm Import"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
Radar,
Tooltip,
} from "recharts";
import type { SkillEntry } from "@planarchy/shared";
interface Props {
skills: SkillEntry[];
}
export function SkillRadarChart({ skills }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
if (entry) setWidth(entry.contentRect.width);
});
ro.observe(el);
setWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);
if (skills.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
<div className="flex items-center justify-center h-48 text-sm text-gray-400 dark:text-gray-500">
No skills recorded yet
</div>
</div>
);
}
const categoryMap = new Map<string, number[]>();
for (const s of skills) {
const cat = s.category ?? "Other";
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
categoryMap.get(cat)!.push(s.proficiency);
}
const data = Array.from(categoryMap.entries())
.map(([category, profs]) => ({
category,
score: Math.round((profs.reduce((a, b) => a + b, 0) / profs.length) * 20),
}))
.sort((a, b) => b.score - a.score)
.slice(0, 8);
if (data.length < 3) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-4">Skill Profile</h2>
<div ref={containerRef} style={{ height: 260 }}>
{width > 0 && (
<RadarChart
width={width}
height={260}
data={data}
margin={{ top: 10, right: 30, bottom: 10, left: 30 }}
>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis
dataKey="category"
tick={{ fontSize: 11, fill: "#6b7280" }}
/>
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0}%`, "Avg proficiency"] as [string, string]}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Radar
name="Skills"
dataKey="score"
stroke="#6366f1"
fill="#6366f1"
fillOpacity={0.2}
strokeWidth={2}
isAnimationActive={false}
/>
</RadarChart>
)}
</div>
</div>
);
}