Files
CapaKraken/apps/web/src/app/(app)/projects/ProjectsClient.tsx
T
Hartmut 82acc56b8d chore: add pre-commit hooks, tighten ESLint, activate Sentry DSN, publish CI coverage (Phase 1)
- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files
- Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error
- Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin
- Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments
- Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example
- Add coverage artifact upload step to CI test job
- Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:49:29 +02:00

988 lines
36 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 { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { createPortal } from "react-dom";
import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
import { 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 { SuccessToast } from "~/components/ui/SuccessToast.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 [filters, setFilters] = useUrlFilters({ search: "", status: "", orderType: "" });
const { status: statusFilter, orderType: orderTypeFilter } = filters;
// Debounced local state for the search text input
const [searchInput, setSearchInput] = useState(filters.search);
const debouncedSearch = useDebounce(searchInput, 300);
const search = filters.search;
// Flush debounced input to URL
useEffect(() => {
setFilters({ search: debouncedSearch });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
// Keep local input in sync when URL changes externally (e.g. back/forward)
useEffect(() => {
setSearchInput(filters.search);
}, [filters.search]);
const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]);
const setOrderTypeFilter = useCallback((v: string) => setFilters({ orderType: v }), [setFilters]);
const [modalOpen, setModalOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [successToast, setSuccessToast] = useState<string | 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() {
setSearchInput("");
setFilters({ search: "", status: "", orderType: "" });
}
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: () => {
setSearchInput("");
setFilters({ search: "" });
},
},
]
: []),
...(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={searchInput}
onChange={(e) => setSearchInput(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="app-action-edit link-hover-underline"
>
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}
onSuccess={(name) =>
setSuccessToast(
editingProject
? `Project "${name}" updated successfully.`
: `Project "${name}" created successfully.`,
)
}
/>
)}
{/* Wizard */}
<ProjectWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}
onSuccess={(shortCode, name) =>
setSuccessToast(`Project "${shortCode}${name}" created successfully.`)
}
/>
<SuccessToast
show={successToast !== null}
message={successToast ?? ""}
onDone={() => setSuccessToast(null)}
/>
</div>
);
}