cd78f72f33
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>
759 lines
32 KiB
TypeScript
759 lines
32 KiB
TypeScript
"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>
|
||
);
|
||
}
|