Files
CapaKraken/apps/web/src/app/(app)/projects/ProjectsClient.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

759 lines
32 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, useEffect, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef } from "@capakraken/shared";
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/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";
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
import { ProjectModal } from "~/components/projects/ProjectModal.js";
import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
// ─── Constants ────────────────────────────────────────────────────────────────
const ALL_STATUSES = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
const ALL_ORDER_TYPES = [
{ value: "BD", label: "BD" },
{ value: "CHARGEABLE", label: "Chargeable" },
{ value: "INTERNAL", label: "Internal" },
{ value: "OVERHEAD", label: "Overhead" },
] as const;
// ─── Sub-components ───────────────────────────────────────────────────────────
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
if (budgetCents === 0) {
return <div className="text-xs text-gray-400">No budget</div>;
}
const cappedPercent = Math.min(utilizationPercent, 100);
let barColor = "bg-green-500";
if (utilizationPercent > 95) barColor = "bg-red-500";
else if (utilizationPercent > 85) barColor = "bg-orange-500";
else if (utilizationPercent > 70) barColor = "bg-yellow-500";
return (
<div className="min-w-[104px] space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
<div className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)} style={{ width: `${cappedPercent}%` }} />
</div>
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
</div>
);
}
function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) {
const utils = trpc.useUtils();
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
const updateStatus = trpc.project.updateStatus.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
onClose();
},
});
// Position the portal dropdown below the trigger button
useEffect(() => {
if (!isOpen || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left });
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
function handleOutsideClick(e: MouseEvent) {
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
onClose();
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [isOpen, onClose]);
return (
<>
<button
ref={triggerRef}
type="button"
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
className={clsx(
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
)}
title="Click to change status"
>
{project.status}
<svg className="w-2.5 h-2.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && createPortal(
<motion.div
ref={panelRef}
initial={{ opacity: 0, scaleY: 0.9 }}
animate={{ opacity: 1, scaleY: 1 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900 origin-top"
style={{ top: pos.top, left: pos.left }}
>
{ALL_STATUSES.map((s) => (
<button
key={s.value}
type="button"
disabled={s.value === project.status || updateStatus.isPending}
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
className={clsx(
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
s.value === project.status
? "cursor-default font-semibold text-gray-400"
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
{s.label}
</span>
</button>
))}
</motion.div>,
document.body,
)}
</>
);
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface ProjectRow {
id: string;
shortCode: string;
name: string;
status: string;
orderType: string;
startDate: string | Date;
endDate: string | Date;
budgetCents: number;
winProbability: number;
totalCostCents: number;
totalPersonDays: number;
utilizationPercent: number;
dynamicFields?: Record<string, unknown> | null;
coverImageUrl?: string | null;
color?: string | null;
}
// ─── Main component ───────────────────────────────────────────────────────────
export function ProjectsClient() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [orderTypeFilter, setOrderTypeFilter] = useState<string>("");
const [modalOpen, setModalOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewCosts } = usePermissions();
const batchUpdateStatus = trpc.project.batchUpdateStatus.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
selection.clear();
},
});
const batchDeleteMutation = trpc.project.batchDelete.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
selection.clear();
},
});
// ─── Favorites ──────────────────────────────────────────────────────────
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({
onMutate: async ({ projectId }) => {
// Cancel any outgoing refetches so they don't overwrite optimistic update
await utils.user.getFavoriteProjectIds.cancel();
// Snapshot previous value
const previous = utils.user.getFavoriteProjectIds.getData();
// Optimistically update the cache
const current = previous ?? [];
const next = current.includes(projectId)
? current.filter((id: string) => id !== projectId)
: [...current, projectId];
utils.user.getFavoriteProjectIds.setData(undefined, next);
return { previous };
},
onError: (_err, _vars, context) => {
// Rollback on error
if (context?.previous !== undefined) {
utils.user.getFavoriteProjectIds.setData(undefined, context.previous);
}
},
onSettled: () => {
void utils.user.getFavoriteProjectIds.invalidate();
},
});
// ─── Custom field columns from global blueprints ──────────────────────────
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
{ target: BlueprintTarget.PROJECT },
{ staleTime: 300_000 },
);
const customColumns = useMemo<ColumnDef[]>(
() =>
(globalFieldDefs ?? [])
.filter((f) => f.showInList)
.map((f) => ({
key: `custom_${f.key}`,
label: f.label,
defaultVisible: false,
hideable: true,
isCustom: true,
fieldType: f.type as string,
})),
[globalFieldDefs],
);
// ─── Column visibility ────────────────────────────────────────────────────
// Filter out budget column if user cannot view costs
const baseColumns = useMemo<ColumnDef[]>(
() => (canViewCosts ? PROJECT_COLUMNS : PROJECT_COLUMNS.filter((c) => c.key !== "budget")),
[canViewCosts],
);
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
"projects",
baseColumns,
customColumns,
);
const defaultKeys = useMemo(
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
[baseColumns],
);
// ─── Infinite query (cursor-based) ────────────────────────────────────────
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
{
search: search || undefined,
status: (statusFilter as ProjectStatus) || undefined,
limit: 50,
},
{
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
staleTime: 15_000,
},
) as {
data:
| {
pages: { projects: ProjectRow[]; nextCursor?: string | null }[];
}
| undefined;
isLoading: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
hasNextPage: boolean | undefined;
};
const allProjects = useMemo(
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
[data],
);
// Client-side orderType filter
const filteredProjects = useMemo(
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
[allProjects, orderTypeFilter],
);
// ─── Sort + row order ─────────────────────────────────────────────────────
const viewPrefs = useViewPrefs("projects");
const { sorted, sortField, sortDir, toggle, reset } = useTableSort(filteredProjects, {
initialField: viewPrefs.savedSort?.field ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
sorted,
viewPrefs,
sortField,
reset,
);
const rowDragRef = useRef<string | null>(null);
const projectIds = projects.map((p) => p.id);
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, statusFilter, orderTypeFilter]);
const handleFetchNext = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function openNewModal() { setEditingProject(null); setModalOpen(true); }
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
function closeModal() { setModalOpen(false); setEditingProject(null); }
function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); }
const exportSelectedCsv = useCallback(() => {
const selected = projects.filter((p) => selection.selectedIds.has(p.id));
if (selected.length === 0) return;
const csv = generateCsv(selected, [
{ header: "Short Code", accessor: (p) => p.shortCode },
{ header: "Name", accessor: (p) => p.name },
{ header: "Status", accessor: (p) => p.status },
{ header: "Order Type", accessor: (p) => p.orderType },
{ header: "Start Date", accessor: (p) => formatDate(p.startDate) },
{ header: "End Date", accessor: (p) => formatDate(p.endDate) },
{ header: "Budget (cents)", accessor: (p) => p.budgetCents },
{ header: "Win Probability", accessor: (p) => p.winProbability },
{ header: "Total Cost (cents)", accessor: (p) => p.totalCostCents },
{ header: "Person Days", accessor: (p) => p.totalPersonDays },
{ header: "Utilization %", accessor: (p) => p.utilizationPercent },
]);
downloadCsv(csv, `projects-export-${new Date().toISOString().slice(0, 10)}.csv`);
}, [projects, selection.selectedIds]);
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
];
// ─── Cell renderer ────────────────────────────────────────────────────────
function renderCell(col: ColumnDef, project: ProjectRow) {
const dynFields = (project.dynamicFields ?? {}) as Record<string, unknown>;
if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey];
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{val != null ? String(val) : "—"}</td>;
}
switch (col.key) {
case "shortCode":
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{project.shortCode}</td>;
case "name":
return (
<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 ? (
<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"
style={{
backgroundColor: (project.color ?? "#6366f1") + "22",
color: project.color ?? "#6366f1",
}}
>
{project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()}
</span>
)}
<span className="truncate">{project.name}</span>
</Link>
</td>
);
case "status":
return (
<td key={col.key} className="px-4 py-3">
<StatusDropdown
project={project}
isOpen={openStatusProjectId === project.id}
onOpen={() => setOpenStatusProjectId(project.id)}
onClose={() => setOpenStatusProjectId(null)}
/>
</td>
);
case "orderType":
return (
<td key={col.key} className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
{project.orderType}
</span>
</td>
);
case "dates":
return (
<td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{formatDate(project.startDate)} {formatDate(project.endDate)}
</td>
);
case "budget":
return (
<td key={col.key} className="px-4 py-3 min-w-[120px]">
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
{formatMoney(project.budgetCents)}
</div>
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
</td>
);
case "allocations":
return (
<td key={col.key} className="px-4 py-3 text-right text-sm">
{project.totalPersonDays > 0 ? (
<span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
<svg className="h-3 w-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{project.totalPersonDays}d
</span>
) : (
<span className="text-gray-400 dark:text-gray-500"></span>
)}
</td>
);
case "shoring":
return (
<td key={col.key} className="px-4 py-3 text-center">
<ShoringBadge projectId={project.id} />
</td>
);
case "responsible":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>;
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"></td>;
}
}
// ─── Header renderer ──────────────────────────────────────────────────────
const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]);
function renderHeader(col: ColumnDef) {
if (SORTABLE_PROJECT_COLS.has(col.key)) {
return (
<SortableColumnHeader
key={col.key}
label={col.label}
field={col.key}
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
);
}
return (
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{col.label}
</th>
);
}
return (
<div className="app-page space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="app-page-title">Projects</h1>
{!isLoading && (
<p className="app-page-subtitle mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
{hasNextPage ? "+" : ""}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setWizardOpen(true)}
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
New Project Wizard
</button>
<button
type="button"
onClick={openNewModal}
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Quick Add
</button>
</div>
</div>
<FilterBar>
<input
type="search"
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="app-input max-w-xs"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="app-select"
>
<option value="">All Statuses</option>
{ALL_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<select
value={orderTypeFilter}
onChange={(e) => setOrderTypeFilter(e.target.value)}
className="app-select"
>
<option value="">All Types</option>
{ALL_ORDER_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<ColumnTogglePanel
allColumns={allColumns}
visibleKeys={visibleKeys}
onSetVisible={setVisible}
defaultKeys={defaultKeys}
/>
{isCustomOrder && (
<button
type="button"
onClick={resetOrder}
className="whitespace-nowrap text-xs text-gray-500 underline transition hover:text-gray-700 dark:hover:text-gray-200"
title="Clear manual row order"
>
Reset order
</button>
)}
</FilterBar>
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
<div className="app-data-table">
{isLoading ? (
<div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">Loading projects</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-gray-200 dark:border-gray-700">
<tr>
{/* Drag handle column */}
<th className="w-8 px-2" />
<th className="w-8 px-2" />
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(projectIds)}
ref={(el) => {
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
}}
onChange={() => selection.toggleAll(projectIds)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</th>
{visibleColumns.map(renderHeader)}
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{projects.map((project, index) => {
const isSelected = selection.selectedIds.has(project.id);
return (
<DraggableTableRow
key={project.id}
id={project.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, project.id)}
className={`table-row-hover hover-lift hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }}
>
<td className="px-2 py-3 w-8">
<button
type="button"
onClick={(e) => { e.stopPropagation(); e.preventDefault(); toggleFavMutation.mutate({ projectId: project.id }); }}
className={`text-sm transition-colors ${favSet.has(project.id) ? "text-amber-500 hover:text-amber-600" : "text-gray-300 hover:text-amber-400 dark:text-gray-600 dark:hover:text-amber-500"}`}
title={favSet.has(project.id) ? "Remove from favorites" : "Add to favorites"}
>
{favSet.has(project.id) ? "★" : "☆"}
</button>
</td>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(project.id)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
{visibleColumns.map((col) => renderCell(col, project))}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEditModal(project as unknown as Project)}
className="link-hover-underline text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
>
Edit
</button>
<Link href={`/projects/${project.id}`} className="link-hover-underline text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200">
View
</Link>
</div>
</td>
</DraggableTableRow>
);
})}
</tbody>
</table>
</div>
{projects.length === 0 && (
<div className="py-14 text-center text-sm text-gray-500">
No projects found.{" "}
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
Create your first project.
</button>
</div>
)}
<InfiniteScrollSentinel
onVisible={handleFetchNext}
isLoading={isFetchingNextPage}
/>
</>
)}
</div>
{/* Batch Status Picker */}
{batchStatusPicker && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} projects</h3>
<div className="flex flex-col gap-1">
{ALL_STATUSES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => {
setConfirmBatchStatus({ ids: selection.selectedArray, status: s.value });
setBatchStatusPicker(false);
}}
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
{s.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Confirm batch status change */}
{confirmBatchStatus && (
<ConfirmDialog
title="Update Project Status"
message={`Set ${confirmBatchStatus.ids.length} project${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
confirmLabel="Update"
onConfirm={() => {
if (confirmBatchStatus) {
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
}
setConfirmBatchStatus(null);
}}
onCancel={() => setConfirmBatchStatus(null)}
/>
)}
{/* Confirm batch delete */}
{confirmBatchDelete && (
<ConfirmDialog
title="Delete Projects"
message={`Permanently delete ${confirmBatchDelete.length} project${confirmBatchDelete.length !== 1 ? "s" : ""}? This will also remove all associated allocations and demands. This action cannot be undone.`}
confirmLabel="Delete All"
variant="danger"
onConfirm={() => {
batchDeleteMutation.mutate({ ids: confirmBatchDelete });
setConfirmBatchDelete(null);
}}
onCancel={() => setConfirmBatchDelete(null)}
/>
)}
{/* Batch Action Bar */}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{ label: "Export Selected", onClick: exportSelectedCsv },
{ label: "Set Status...", onClick: () => setBatchStatusPicker(true) },
{
label: `Delete (${selection.count})`,
variant: "danger" as const,
onClick: () => setConfirmBatchDelete(selection.selectedArray),
disabled: batchDeleteMutation.isPending,
},
]}
/>
{/* Modal */}
{modalOpen && <ProjectModal project={editingProject} onClose={closeModal} />}
{/* Wizard */}
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
</div>
);
}