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>
186 lines
7.5 KiB
TypeScript
186 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { formatCents } from "~/lib/format.js";
|
|
import type { SkillEntry } from "@capakraken/shared";
|
|
|
|
interface ResourceHoverCardProps {
|
|
resourceId: string;
|
|
anchorEl: HTMLElement;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState({ left: 0, top: 0 });
|
|
|
|
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
|
|
{ id: resourceId },
|
|
{ staleTime: 60_000 },
|
|
);
|
|
|
|
// Position relative to anchor element
|
|
useEffect(() => {
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
setPos({
|
|
left: rect.right + 8,
|
|
top: Math.min(rect.top, window.innerHeight - 320),
|
|
});
|
|
}, [anchorEl]);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [onClose, anchorEl]);
|
|
|
|
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
|
|
const mainSkills = skills.filter((s) => s.isMainSkill);
|
|
const topSkills = skills
|
|
.filter((s) => !s.isMainSkill && s.proficiency >= 4)
|
|
.sort((a, b) => b.proficiency - a.proficiency)
|
|
.slice(0, 6);
|
|
|
|
const popoverStyle: React.CSSProperties = {
|
|
position: "fixed",
|
|
left: Math.min(pos.left, window.innerWidth - 300),
|
|
top: pos.top,
|
|
zIndex: 50,
|
|
width: 280,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-resource-hover-card="true"
|
|
style={popoverStyle}
|
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
|
onMouseLeave={onClose}
|
|
>
|
|
{isLoading || !data ? (
|
|
<div className="p-4 text-xs text-gray-400 dark:text-gray-500">Loading...</div>
|
|
) : (
|
|
<>
|
|
{/* Header */}
|
|
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-750">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
|
|
{data.displayName.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
|
|
{data.displayName}
|
|
</div>
|
|
<div className="text-[11px] text-gray-400 dark:text-gray-500">{data.eid}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 space-y-2.5 text-xs">
|
|
{/* Role & Chapter */}
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
|
{data.areaRole && (
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Role</div>
|
|
<div className="font-medium text-gray-700 dark:text-gray-200 flex items-center gap-1">
|
|
{data.areaRole.color && (
|
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: data.areaRole.color }} />
|
|
)}
|
|
{data.areaRole.name}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{data.chapter && (
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chapter</div>
|
|
<div className="font-medium text-gray-700 dark:text-gray-200">{data.chapter}</div>
|
|
</div>
|
|
)}
|
|
{data.country && (
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Location</div>
|
|
<div className="font-medium text-gray-700 dark:text-gray-200">{data.country.name}</div>
|
|
</div>
|
|
)}
|
|
{data.managementLevel && (
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Level</div>
|
|
<div className="font-medium text-gray-700 dark:text-gray-200">{data.managementLevel.name}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Rates */}
|
|
<div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-gray-50 dark:bg-gray-750">
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
|
|
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
|
{formatCents(data.lcrCents)} {data.currency}/h
|
|
</div>
|
|
</div>
|
|
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
|
|
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
|
{formatCents(data.ucrCents)} {data.currency}/h
|
|
</div>
|
|
</div>
|
|
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chg%</div>
|
|
<div className="font-semibold text-gray-700 dark:text-gray-200">{data.chargeabilityTarget}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Skills */}
|
|
{mainSkills.length > 0 && (
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Main Skills</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{mainSkills.map((s) => (
|
|
<span
|
|
key={s.skill}
|
|
className="inline-flex items-center px-1.5 py-0.5 rounded-md text-[11px] font-medium bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300"
|
|
>
|
|
{s.skill}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Top Skills */}
|
|
{topSkills.length > 0 && (
|
|
<div>
|
|
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Top Skills</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{topSkills.map((s) => (
|
|
<span
|
|
key={s.skill}
|
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[11px] bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
|
>
|
|
{s.skill}
|
|
<span className="text-[9px] text-gray-400 dark:text-gray-500">L{s.proficiency}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* No skills */}
|
|
{skills.length === 0 && (
|
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">No skills imported yet.</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|