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 type { Project, ColumnDef } from "@planarchy/shared";
|
||||||
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
|
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
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">
|
<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">
|
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
|
||||||
{project.coverImageUrl ? (
|
{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
|
<span
|
||||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
|
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 { BlueprintTarget, ResourceType } from "@planarchy/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { formatMoney } from "~/lib/format.js";
|
import { formatMoney } from "~/lib/format.js";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { ResourceModal } from "~/components/resources/ResourceModal.js";
|
import { ResourceModal } from "~/components/resources/ResourceModal.js";
|
||||||
import { ImportModal } from "~/components/resources/ImportModal.js";
|
|
||||||
import { BulkEditModal } from "~/components/resources/BulkEditModal.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 { useSelection } from "~/hooks/useSelection.js";
|
||||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function BatchSkillImport() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
const result = parseSkillMatrixWorkbook(buffer);
|
const result = await parseSkillMatrixWorkbook(buffer);
|
||||||
|
|
||||||
let roleId: string | undefined;
|
let roleId: string | undefined;
|
||||||
let matchedRoleName: 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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useId } from "react";
|
import { useState, useId } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||||
import { useTableSort } from "~/hooks/useTableSort.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 { trpc } from "~/lib/trpc/client.js";
|
||||||
import * as XLSX from "xlsx";
|
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_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
|
||||||
|
|
||||||
// Tailwind class sets per proficiency level (1–5), dark-mode aware
|
// Tailwind class sets per proficiency level (1–5), dark-mode aware
|
||||||
const PROFICIENCY_CLASSES = [
|
const PROFICIENCY_CLASSES = [
|
||||||
@@ -87,8 +81,9 @@ export function SkillsAnalytics() {
|
|||||||
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportXlsx() {
|
async function exportXlsx() {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
const XLSX = await import("xlsx");
|
||||||
const rows = data.aggregated.map((e) => ({
|
const rows = data.aggregated.map((e) => ({
|
||||||
Skill: e.skill,
|
Skill: e.skill,
|
||||||
Category: e.category,
|
Category: e.category,
|
||||||
@@ -413,21 +408,7 @@ export function SkillsAnalytics() {
|
|||||||
{top20.length > 0 && (
|
{top20.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
<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>
|
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
<SkillDistributionChart data={top20} />
|
||||||
<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>
|
|
||||||
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light → dark = low → high)</p>
|
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light → dark = low → high)</p>
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ReferenceLine,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
const COLORS = [
|
const PeakTimesChart = dynamic(
|
||||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
() => import("~/components/dashboard/widgets/PeakTimesChart.js"),
|
||||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
|
||||||
];
|
);
|
||||||
|
|
||||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||||
const granularity = (config.granularity as "week" | "month") || "month";
|
const granularity = (config.granularity as "week" | "month") || "month";
|
||||||
@@ -107,31 +97,7 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
|
|
||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
{chartData.length === 0 ? (
|
<PeakTimesChart chartData={chartData} groups={groups} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,26 +2,70 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { EstimateExportFormat } from "@planarchy/shared";
|
import { EstimateExportFormat } from "@planarchy/shared";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { EstimateWorkspaceDraftEditor } from "~/components/estimates/EstimateWorkspaceDraftEditor.js";
|
|
||||||
import { WeeklyPhasingView } from "~/components/estimates/WeeklyPhasingView.js";
|
|
||||||
import type {
|
import type {
|
||||||
EstimateWorkspaceView,
|
EstimateWorkspaceView,
|
||||||
WorkspaceTab,
|
WorkspaceTab,
|
||||||
} from "~/components/estimates/EstimateWorkspace.types.js";
|
} 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||||
import { formatDateLong } from "~/lib/format.js";
|
import { formatDateLong } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.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 }> = [
|
const TABS: Array<{ id: WorkspaceTab; label: string }> = [
|
||||||
{ id: "overview", label: "Overview" },
|
{ id: "overview", label: "Overview" },
|
||||||
{ id: "assumptions", label: "Assumptions" },
|
{ id: "assumptions", label: "Assumptions" },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
|||||||
import type { Route } from "next";
|
import type { Route } from "next";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { clsx } from "clsx";
|
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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import { PreferencesModal } from "./PreferencesModal.js";
|
import { PreferencesModal } from "./PreferencesModal.js";
|
||||||
import { ThemeProvider } from "./ThemeProvider.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 */
|
/* Sidebar component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -370,34 +424,17 @@ function SidebarContent({
|
|||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => (
|
||||||
const isActive = activeHrefSet.has(item.href);
|
<NavItemLink
|
||||||
return (
|
key={item.href}
|
||||||
<NavTooltip key={item.href} label={item.label} show={sidebarCollapsed}>
|
href={item.href}
|
||||||
<Link
|
label={item.label}
|
||||||
href={item.href as Route}
|
icon={item.icon}
|
||||||
onClick={handleLinkClick}
|
isActive={activeHrefSet.has(item.href)}
|
||||||
className={clsx(
|
collapsed={sidebarCollapsed}
|
||||||
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
|
onClick={handleLinkClick}
|
||||||
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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -425,32 +462,17 @@ function SidebarContent({
|
|||||||
|
|
||||||
if (sidebarCollapsed) {
|
if (sidebarCollapsed) {
|
||||||
// In collapsed mode, show sub-group items directly as icon-only
|
// In collapsed mode, show sub-group items directly as icon-only
|
||||||
return entry.items.map((item) => {
|
return entry.items.map((item) => (
|
||||||
const isActive = activeHrefSet.has(item.href);
|
<NavItemLink
|
||||||
return (
|
key={item.href}
|
||||||
<NavTooltip key={item.href} label={item.label} show>
|
href={item.href}
|
||||||
<Link
|
label={item.label}
|
||||||
href={item.href as Route}
|
icon={item.icon}
|
||||||
onClick={handleLinkClick}
|
isActive={activeHrefSet.has(item.href)}
|
||||||
className={clsx(
|
collapsed
|
||||||
"group relative flex items-center justify-center rounded-2xl px-2 py-2 text-sm font-medium transition-colors",
|
onClick={handleLinkClick}
|
||||||
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 (
|
return (
|
||||||
@@ -517,31 +539,16 @@ function SidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActive = activeHrefSet.has(entry.href);
|
|
||||||
return (
|
return (
|
||||||
<NavTooltip key={entry.href} label={entry.label} show={sidebarCollapsed}>
|
<NavItemLink
|
||||||
<Link
|
key={entry.href}
|
||||||
href={entry.href as Route}
|
href={entry.href}
|
||||||
onClick={handleLinkClick}
|
label={entry.label}
|
||||||
className={clsx(
|
icon={entry.icon}
|
||||||
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
|
isActive={activeHrefSet.has(entry.href)}
|
||||||
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
|
collapsed={sidebarCollapsed}
|
||||||
isActive
|
onClick={handleLinkClick}
|
||||||
? "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>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
|
import NextImage from "next/image";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
interface CoverArtSectionProps {
|
interface CoverArtSectionProps {
|
||||||
@@ -149,14 +150,18 @@ export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, pr
|
|||||||
{/* Cover image or placeholder */}
|
{/* Cover image or placeholder */}
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<NextImage
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={`Cover art for ${projectName}`}
|
alt={`Cover art for ${projectName}`}
|
||||||
|
width={1024}
|
||||||
|
height={1024}
|
||||||
className="w-full object-cover"
|
className="w-full object-cover"
|
||||||
style={{
|
style={{
|
||||||
height: "clamp(16rem, 22vw, 22rem)",
|
height: "clamp(16rem, 22vw, 22rem)",
|
||||||
objectPosition: `center ${focusY}%`,
|
objectPosition: `center ${focusY}%`,
|
||||||
}}
|
}}
|
||||||
|
unoptimized={imageUrl.startsWith("data:")}
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
{/* Gradient overlay at bottom for readability */}
|
{/* 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" />
|
<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 { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
|
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { formatDate, formatMoney } from "~/lib/format.js";
|
import { formatDate, formatMoney } from "~/lib/format.js";
|
||||||
import { ResourceModal } from "./ResourceModal.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";
|
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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||||
import { FadeIn } from "~/components/ui/FadeIn.js";
|
import { FadeIn } from "~/components/ui/FadeIn.js";
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
const parsed = parseSkillMatrixWorkbook(buffer);
|
const parsed = await parseSkillMatrixWorkbook(buffer);
|
||||||
|
|
||||||
// Fuzzy match areaOfExpertise → roleId
|
// Fuzzy match areaOfExpertise → roleId
|
||||||
let roleId: string | undefined;
|
let roleId: string | undefined;
|
||||||
|
|||||||
+24
-29
@@ -1,37 +1,32 @@
|
|||||||
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.
|
* Parse an Excel (.xlsx, .xls) or CSV file to an array of row objects.
|
||||||
* Keys come from the first row (headers).
|
* Keys come from the first row (headers).
|
||||||
*/
|
*/
|
||||||
export function parseSpreadsheet(file: File): Promise<Record<string, string>[]> {
|
export async function parseSpreadsheet(file: File): Promise<Record<string, string>[]> {
|
||||||
return new Promise((resolve, reject) => {
|
const XLSX = await getXLSX();
|
||||||
const reader = new FileReader();
|
const buffer = await file.arrayBuffer();
|
||||||
reader.onload = (e) => {
|
const data = new Uint8Array(buffer);
|
||||||
try {
|
const workbook = XLSX.read(data, { type: "array" });
|
||||||
const data = new Uint8Array(e.target!.result as ArrayBuffer);
|
const sheetName = workbook.SheetNames[0];
|
||||||
const workbook = XLSX.read(data, { type: "array" });
|
if (!sheetName) {
|
||||||
const sheetName = workbook.SheetNames[0];
|
return [];
|
||||||
if (!sheetName) {
|
}
|
||||||
resolve([]);
|
const sheet = workbook.Sheets[sheetName];
|
||||||
return;
|
if (!sheet) {
|
||||||
}
|
return [];
|
||||||
const sheet = workbook.Sheets[sheetName];
|
}
|
||||||
if (!sheet) {
|
return XLSX.utils.sheet_to_json<Record<string, string>>(sheet, {
|
||||||
resolve([]);
|
raw: false,
|
||||||
return;
|
defval: "",
|
||||||
}
|
|
||||||
const rows = 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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import * as XLSX from "xlsx";
|
|
||||||
import type { SkillEntry } from "@planarchy/shared";
|
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 {
|
export interface ParsedEmployeeInfo {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
areaOfExpertise?: 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.
|
* Parse a skill matrix workbook (xlsx ArrayBuffer) into structured data.
|
||||||
* Returns ParsedSkillMatrix with employeeInfo and merged skills array.
|
* 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 workbook = XLSX.read(new Uint8Array(data), { type: "array" });
|
||||||
|
|
||||||
const employeeSheet = workbook.Sheets["Employee Information"];
|
const employeeSheet = workbook.Sheets["Employee Information"];
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
|||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
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,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user