Files
Nexus/apps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
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>
2026-03-27 13:18:09 +01:00

308 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatCents, formatMoney } from "~/lib/format.js";
import { ProjectStatus } from "@capakraken/shared/types";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
const status = (config.status as ProjectStatus) || undefined;
const search = (config.search as string) || "";
const { data: projects, isLoading } = trpc.project.listWithCosts.useQuery(
{ status, search: search || undefined },
{ staleTime: 60_000 },
);
type SortKey = "code" | "name" | "status" | "cost" | "personDays";
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("asc");
}
}
if (isLoading) {
return (
<div className="flex flex-col gap-2 pt-1">
{/* header row */}
<div className="flex gap-3 px-3 py-2">
{[40, 120, 80, 60, 60].map((w, i) => (
<div
key={i}
className="h-2.5 shimmer-skeleton rounded"
style={{ width: w }}
/>
))}
</div>
{/* data rows */}
{[...Array(6)].map((_, i) => (
<div
key={i}
className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800"
>
<div className="h-3 w-10 shimmer-skeleton rounded font-mono" />
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="h-3 w-16 shimmer-skeleton rounded" />
<div className="h-3 w-12 shimmer-skeleton rounded" />
<div className="h-3 w-10 shimmer-skeleton rounded" />
</div>
))}
</div>
);
}
interface ProjectRow {
id: string;
shortCode: string;
name: string;
status: string;
budgetCents: number;
totalCostCents: number;
totalPersonDays: number;
utilizationPercent: number;
}
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ??
[]) as ProjectRow[];
const sorted = [...list].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "code":
return mult * a.shortCode.localeCompare(b.shortCode);
case "name":
return mult * a.name.localeCompare(b.name);
case "status":
return mult * a.status.localeCompare(b.status);
case "cost":
return mult * (a.totalCostCents - b.totalCostCents);
case "personDays":
return mult * (a.totalPersonDays - b.totalPersonDays);
default:
return 0;
}
});
return (
<div className="flex flex-col h-full gap-3">
{/* Filters */}
<div className="flex gap-2">
<input
type="search"
placeholder="Search projects..."
value={search}
onChange={(e) => onConfigChange?.({ search: e.target.value })}
className="app-input min-w-0 flex-1 text-xs"
/>
<select
value={status ?? ""}
onChange={(e) => onConfigChange?.({ status: e.target.value || undefined })}
className="app-select text-xs"
>
<option value="">All Statuses</option>
{Object.values(ProjectStatus).map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Table */}
<div className="app-data-table flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("code")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Code
<span className="text-[10px] ml-0.5">
{sortKey === "code" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Unique short code identifying the project (e.g. PRJ-001)." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("name")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Name
<span className="text-[10px] ml-0.5">
{sortKey === "name" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Project name. Click to open the project detail page." />
</span>
</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">
<span className="inline-flex items-center">
<button
type="button"
onClick={() => toggleSort("status")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Status
<span className="text-[10px] ml-0.5">
{sortKey === "status" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Current project lifecycle status: DRAFT, ACTIVE, ON_HOLD, COMPLETED, or CANCELLED." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button
type="button"
onClick={() => toggleSort("cost")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
Cost
<span className="text-[10px] ml-0.5">
{sortKey === "cost" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip
content="Sum of (resource LCR × hours per day × working days) across all non-cancelled allocations on this project."
width="w-72"
/>
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
<button
type="button"
onClick={() => toggleSort("personDays")}
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
>
PD
<span className="text-[10px] ml-0.5">
{sortKey === "personDays" ? (
sortDir === "asc" ? (
"▲"
) : (
"▼"
)
) : (
<span className="text-gray-300"></span>
)}
</span>
</button>
<InfoTooltip content="Total person-days across all non-cancelled allocations." />
</span>
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">
<span className="inline-flex items-center justify-end">
Budget
<InfoTooltip content="Project budget in EUR. The colored dot indicates utilization: green = healthy, amber = above 80%, red = over budget." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{sorted.map((p) => {
const overBudget = p.budgetCents > 0 && p.totalCostCents > p.budgetCents;
const util = p.utilizationPercent;
return (
<tr key={p.id} className="transition hover:bg-gray-50 dark:hover:bg-gray-800/60">
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">
{p.shortCode}
</td>
<td className="px-3 py-2 max-w-[180px] truncate font-medium">
<Link
href={`/projects/${p.id}`}
className="text-gray-900 dark:text-gray-100 hover:text-brand-600 dark:hover:text-brand-400 hover:underline"
>
{p.name}
</Link>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded-full text-xs ${STATUS_COLORS[p.status] ?? ""}`}
>
{p.status}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
{formatCents(p.totalCostCents)}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
<AnimatedNumber value={p.totalPersonDays} suffix="d" />
</td>
<td className="px-3 py-2 text-right">
{p.budgetCents > 0 ? (
<div className="flex items-center justify-end gap-1.5">
<span className="text-gray-700 dark:text-gray-200">
{formatMoney(p.budgetCents)}
</span>
<span
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${overBudget ? "bg-red-500" : util >= 80 ? "bg-amber-500" : "bg-green-500"}`}
title={`${util}% utilized${overBudget ? " — over budget!" : ""}`}
/>
</div>
) : (
<span className="text-gray-400 dark:text-gray-600"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{list.length === 0 && (
<div className="py-8 text-center text-sm text-gray-400">No projects found.</div>
)}
</div>
</div>
);
}