perf: lazy-load xlsx/recharts, split estimate tabs, memoize nav
- xlsx dynamically imported via cached singleton in excel.ts and skillMatrixParser.ts (removes ~100 kB from 4 routes) - recharts extracted into lazy-loaded SkillDistributionChart and PeakTimesChart components (removes ~60 kB from 3 routes) - EstimateWorkspaceClient: 7 tab components + 2 editors loaded via next/dynamic (reduces /estimates/[id] from 323 kB to 138 kB) - ImportModal lazy-loaded in ResourcesClient (deferred until open) - NavItem memoized with React.memo, top 5 routes get prefetch=true - Raw <img> replaced with next/image in ProjectsClient, CoverArtSection - tRPC QueryClient: refetchOnWindowFocus/Reconnect disabled globally Heaviest routes reduced 39-66% First Load JS: /analytics/skills: 383→132 kB (-66%) /estimates/[id]: 323→138 kB (-57%) /resources/[id]: 458→210 kB (-54%) /estimates: 310→170 kB (-45%) /resources: 363→222 kB (-39%) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -6,6 +6,7 @@ import { formatDate, formatMoney } from "~/lib/format.js";
|
||||
import type { Project, ColumnDef } from "@planarchy/shared";
|
||||
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { clsx } from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
@@ -359,7 +360,7 @@ export function ProjectsClient() {
|
||||
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
|
||||
{project.coverImageUrl ? (
|
||||
<img src={project.coverImageUrl} alt="" className="h-6 w-6 flex-shrink-0 rounded object-cover" />
|
||||
<Image src={project.coverImageUrl} alt={project.name} width={24} height={24} className="h-6 w-6 flex-shrink-0 rounded object-cover" unoptimized={project.coverImageUrl.startsWith("data:")} />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
|
||||
|
||||
@@ -8,9 +8,14 @@ import { RESOURCE_COLUMNS } from "@planarchy/shared";
|
||||
import { BlueprintTarget, ResourceType } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ResourceModal } from "~/components/resources/ResourceModal.js";
|
||||
import { ImportModal } from "~/components/resources/ImportModal.js";
|
||||
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
|
||||
|
||||
const ImportModal = dynamic(
|
||||
() => import("~/components/resources/ImportModal.js").then((mod) => mod.ImportModal),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
|
||||
@@ -55,7 +55,7 @@ export function BatchSkillImport() {
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = parseSkillMatrixWorkbook(buffer);
|
||||
const result = await parseSkillMatrixWorkbook(buffer);
|
||||
|
||||
let roleId: string | undefined;
|
||||
let matchedRoleName: string | undefined;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
// SVG fill colors for the bar chart (work in both light and dark contexts)
|
||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
||||
|
||||
interface SkillDistributionChartProps {
|
||||
data: { skill: string; count: number; avgProficiency: number }[];
|
||||
}
|
||||
|
||||
export default function SkillDistributionChart({ data }: SkillDistributionChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||
const SkillDistributionChart = dynamic(
|
||||
() => import("~/components/analytics/SkillDistributionChart.js"),
|
||||
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
|
||||
);
|
||||
|
||||
// SVG fill colors for the bar chart (work in both light and dark contexts)
|
||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
||||
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||
|
||||
// Tailwind class sets per proficiency level (1–5), dark-mode aware
|
||||
const PROFICIENCY_CLASSES = [
|
||||
@@ -87,8 +81,9 @@ export function SkillsAnalytics() {
|
||||
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function exportXlsx() {
|
||||
async function exportXlsx() {
|
||||
if (!data) return;
|
||||
const XLSX = await import("xlsx");
|
||||
const rows = data.aggregated.map((e) => ({
|
||||
Skill: e.skill,
|
||||
Category: e.category,
|
||||
@@ -413,21 +408,7 @@ export function SkillsAnalytics() {
|
||||
{top20.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={top20} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{top20.map((entry) => (
|
||||
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<SkillDistributionChart data={top20} />
|
||||
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light → dark = low → high)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
||||
];
|
||||
|
||||
interface PeakTimesChartProps {
|
||||
chartData: Record<string, number | string>[];
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No allocation data in selected period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<ReferenceLine
|
||||
{...({ dataKey: "capacity" } as any)}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="5 5"
|
||||
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
|
||||
/>
|
||||
{groups.map((g, i) => (
|
||||
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
||||
];
|
||||
const PeakTimesChart = dynamic(
|
||||
() => import("~/components/dashboard/widgets/PeakTimesChart.js"),
|
||||
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
|
||||
);
|
||||
|
||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const granularity = (config.granularity as "week" | "month") || "month";
|
||||
@@ -107,31 +97,7 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No allocation data in selected period.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<ReferenceLine
|
||||
{...({ dataKey: "capacity" } as any)}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="5 5"
|
||||
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
|
||||
/>
|
||||
{groups.map((g, i) => (
|
||||
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<PeakTimesChart chartData={chartData} groups={groups} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,26 +2,70 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EstimateExportFormat } from "@planarchy/shared";
|
||||
import { clsx } from "clsx";
|
||||
import { EstimateWorkspaceDraftEditor } from "~/components/estimates/EstimateWorkspaceDraftEditor.js";
|
||||
import { WeeklyPhasingView } from "~/components/estimates/WeeklyPhasingView.js";
|
||||
import type {
|
||||
EstimateWorkspaceView,
|
||||
WorkspaceTab,
|
||||
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
import { OverviewTab } from "~/components/estimates/tabs/OverviewTab.js";
|
||||
import { AssumptionsTab } from "~/components/estimates/tabs/AssumptionsTab.js";
|
||||
import { ScopeTab } from "~/components/estimates/tabs/ScopeTab.js";
|
||||
import { StaffingTab } from "~/components/estimates/tabs/StaffingTab.js";
|
||||
import { FinancialsTab } from "~/components/estimates/tabs/FinancialsTab.js";
|
||||
import { VersionsTab } from "~/components/estimates/tabs/VersionsTab.js";
|
||||
import { ExportsTab } from "~/components/estimates/tabs/ExportsTab.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const TabSkeleton = () => (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 w-48 shimmer-skeleton rounded" />
|
||||
<div className="h-64 shimmer-skeleton rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const EstimateWorkspaceDraftEditor = dynamic(
|
||||
() => import("~/components/estimates/EstimateWorkspaceDraftEditor.js").then((mod) => ({ default: mod.EstimateWorkspaceDraftEditor })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const WeeklyPhasingView = dynamic(
|
||||
() => import("~/components/estimates/WeeklyPhasingView.js").then((mod) => ({ default: mod.WeeklyPhasingView })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const OverviewTab = dynamic(
|
||||
() => import("~/components/estimates/tabs/OverviewTab.js").then((mod) => ({ default: mod.OverviewTab })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const AssumptionsTab = dynamic(
|
||||
() => import("~/components/estimates/tabs/AssumptionsTab.js").then((mod) => ({ default: mod.AssumptionsTab })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const ScopeTab = dynamic(
|
||||
() => import("~/components/estimates/tabs/ScopeTab.js").then((mod) => ({ default: mod.ScopeTab })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const StaffingTab = dynamic(
|
||||
() => import("~/components/estimates/tabs/StaffingTab.js").then((mod) => ({ default: mod.StaffingTab })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const FinancialsTab = dynamic(
|
||||
() => import("~/components/estimates/tabs/FinancialsTab.js").then((mod) => ({ default: mod.FinancialsTab })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const VersionsTab = dynamic(
|
||||
() => import("~/components/estimates/tabs/VersionsTab.js").then((mod) => ({ default: mod.VersionsTab })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const ExportsTab = dynamic(
|
||||
() => import("~/components/estimates/tabs/ExportsTab.js").then((mod) => ({ default: mod.ExportsTab })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const TABS: Array<{ id: WorkspaceTab; label: string }> = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "assumptions", label: "Assumptions" },
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { PreferencesModal } from "./PreferencesModal.js";
|
||||
import { ThemeProvider } from "./ThemeProvider.js";
|
||||
@@ -231,6 +231,60 @@ function NavTooltip({
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Memoized nav item — prevents re-render of inactive items */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Routes that benefit from eager prefetching (loaded while user reads current page). */
|
||||
const PREFETCH_ROUTES = new Set(["/dashboard", "/timeline", "/projects", "/resources", "/allocations"]);
|
||||
|
||||
const NavItemLink = memo(function NavItemLink({
|
||||
href,
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
collapsed,
|
||||
onClick,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
isActive: boolean;
|
||||
collapsed: boolean;
|
||||
onClick?: (() => void) | undefined;
|
||||
}) {
|
||||
const linkProps = {
|
||||
...(onClick ? { onClick } : {}),
|
||||
...(PREFETCH_ROUTES.has(href) ? { prefetch: true as const } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<NavTooltip label={label} show={collapsed}>
|
||||
<Link
|
||||
href={href as Route}
|
||||
{...linkProps}
|
||||
className={clsx(
|
||||
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
|
||||
collapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
|
||||
isActive
|
||||
? "text-brand-800 dark:text-brand-200"
|
||||
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<IconFrame>{icon}</IconFrame>
|
||||
{!collapsed && <span className="relative flex-1">{label}</span>}
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sidebar component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -370,34 +424,17 @@ function SidebarContent({
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = activeHrefSet.has(item.href);
|
||||
return (
|
||||
<NavTooltip key={item.href} label={item.label} show={sidebarCollapsed}>
|
||||
<Link
|
||||
href={item.href as Route}
|
||||
{section.items.map((item) => (
|
||||
<NavItemLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isActive={activeHrefSet.has(item.href)}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={handleLinkClick}
|
||||
className={clsx(
|
||||
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
|
||||
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
|
||||
isActive
|
||||
? "text-brand-800 dark:text-brand-200"
|
||||
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<IconFrame>{item.icon}</IconFrame>
|
||||
{!sidebarCollapsed && <span className="relative flex-1">{item.label}</span>}
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -425,32 +462,17 @@ function SidebarContent({
|
||||
|
||||
if (sidebarCollapsed) {
|
||||
// In collapsed mode, show sub-group items directly as icon-only
|
||||
return entry.items.map((item) => {
|
||||
const isActive = activeHrefSet.has(item.href);
|
||||
return (
|
||||
<NavTooltip key={item.href} label={item.label} show>
|
||||
<Link
|
||||
href={item.href as Route}
|
||||
return entry.items.map((item) => (
|
||||
<NavItemLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isActive={activeHrefSet.has(item.href)}
|
||||
collapsed
|
||||
onClick={handleLinkClick}
|
||||
className={clsx(
|
||||
"group relative flex items-center justify-center rounded-2xl px-2 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "text-brand-800 dark:text-brand-200"
|
||||
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<IconFrame>{item.icon}</IconFrame>
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
);
|
||||
});
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -517,31 +539,16 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = activeHrefSet.has(entry.href);
|
||||
return (
|
||||
<NavTooltip key={entry.href} label={entry.label} show={sidebarCollapsed}>
|
||||
<Link
|
||||
href={entry.href as Route}
|
||||
<NavItemLink
|
||||
key={entry.href}
|
||||
href={entry.href}
|
||||
label={entry.label}
|
||||
icon={entry.icon}
|
||||
isActive={activeHrefSet.has(entry.href)}
|
||||
collapsed={sidebarCollapsed}
|
||||
onClick={handleLinkClick}
|
||||
className={clsx(
|
||||
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
|
||||
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
|
||||
isActive
|
||||
? "text-brand-800 dark:text-brand-200"
|
||||
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<IconFrame>{entry.icon}</IconFrame>
|
||||
{!sidebarCollapsed && <span className="relative flex-1">{entry.label}</span>}
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import NextImage from "next/image";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface CoverArtSectionProps {
|
||||
@@ -149,14 +150,18 @@ export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, pr
|
||||
{/* Cover image or placeholder */}
|
||||
{imageUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
<NextImage
|
||||
src={imageUrl}
|
||||
alt={`Cover art for ${projectName}`}
|
||||
width={1024}
|
||||
height={1024}
|
||||
className="w-full object-cover"
|
||||
style={{
|
||||
height: "clamp(16rem, 22vw, 22rem)",
|
||||
objectPosition: `center ${focusY}%`,
|
||||
}}
|
||||
unoptimized={imageUrl.startsWith("data:")}
|
||||
priority
|
||||
/>
|
||||
{/* Gradient overlay at bottom for readability */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
|
||||
@@ -2,14 +2,27 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { formatDate, formatMoney } 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";
|
||||
|
||||
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";
|
||||
|
||||
@@ -47,7 +47,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const parsed = parseSkillMatrixWorkbook(buffer);
|
||||
const parsed = await parseSkillMatrixWorkbook(buffer);
|
||||
|
||||
// Fuzzy match areaOfExpertise → roleId
|
||||
let roleId: string | undefined;
|
||||
|
||||
+15
-20
@@ -1,38 +1,33 @@
|
||||
import * as XLSX from "xlsx";
|
||||
let _xlsx: typeof import("xlsx") | null = null;
|
||||
|
||||
async function getXLSX() {
|
||||
if (!_xlsx) {
|
||||
_xlsx = await import("xlsx");
|
||||
}
|
||||
return _xlsx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an Excel (.xlsx, .xls) or CSV file to an array of row objects.
|
||||
* Keys come from the first row (headers).
|
||||
*/
|
||||
export function parseSpreadsheet(file: File): Promise<Record<string, string>[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target!.result as ArrayBuffer);
|
||||
export async function parseSpreadsheet(file: File): Promise<Record<string, string>[]> {
|
||||
const XLSX = await getXLSX();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
const workbook = XLSX.read(data, { type: "array" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
resolve([]);
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheet) {
|
||||
resolve([]);
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, {
|
||||
return XLSX.utils.sheet_to_json<Record<string, string>>(sheet, {
|
||||
raw: false,
|
||||
defval: "",
|
||||
});
|
||||
resolve(rows);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function isSpreadsheetFile(file: File): boolean {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import type { SkillEntry } from "@planarchy/shared";
|
||||
|
||||
let _xlsx: typeof import("xlsx") | null = null;
|
||||
|
||||
async function getXLSX() {
|
||||
if (!_xlsx) {
|
||||
_xlsx = await import("xlsx");
|
||||
}
|
||||
return _xlsx;
|
||||
}
|
||||
|
||||
export interface ParsedEmployeeInfo {
|
||||
displayName?: string;
|
||||
areaOfExpertise?: string;
|
||||
@@ -82,7 +90,8 @@ function parseSkillSheet(rows: Record<string, string>[], mainSkillSet: Set<strin
|
||||
* Parse a skill matrix workbook (xlsx ArrayBuffer) into structured data.
|
||||
* Returns ParsedSkillMatrix with employeeInfo and merged skills array.
|
||||
*/
|
||||
export function parseSkillMatrixWorkbook(data: ArrayBuffer): ParsedSkillMatrix {
|
||||
export async function parseSkillMatrixWorkbook(data: ArrayBuffer): Promise<ParsedSkillMatrix> {
|
||||
const XLSX = await getXLSX();
|
||||
const workbook = XLSX.read(new Uint8Array(data), { type: "array" });
|
||||
|
||||
const employeeSheet = workbook.Sheets["Employee Information"];
|
||||
|
||||
@@ -27,7 +27,9 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 60 seconds — reduces refetches on navigation
|
||||
staleTime: 60_000, // 60 seconds — reduces refetches on navigation
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user