"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 "@planarchy/shared"; import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/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
No budget
; } 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 (
{utilizationPercent.toFixed(0)}% used
); } function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) { const utils = trpc.useUtils(); const triggerRef = useRef(null); const panelRef = useRef(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 ( <> {isOpen && createPortal( {ALL_STATUSES.map((s) => ( ))} , 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 | null; coverImageUrl?: string | null; color?: string | null; } // ─── Main component ─────────────────────────────────────────────────────────── export function ProjectsClient() { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [orderTypeFilter, setOrderTypeFilter] = useState(""); const [modalOpen, setModalOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [editingProject, setEditingProject] = useState(null); const [openStatusProjectId, setOpenStatusProjectId] = useState(null); const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); const [confirmBatchDelete, setConfirmBatchDelete] = useState(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( () => (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( () => (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; 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(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; if (col.isCustom) { const fieldKey = col.key.replace(/^custom_/, ""); const val = dynFields[fieldKey]; return {val != null ? String(val) : "—"}; } switch (col.key) { case "shortCode": return {project.shortCode}; case "name": return ( {project.coverImageUrl ? ( {project.name} ) : ( {project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()} )} {project.name} ); case "status": return ( setOpenStatusProjectId(project.id)} onClose={() => setOpenStatusProjectId(null)} /> ); case "orderType": return ( {project.orderType} ); case "dates": return ( {formatDate(project.startDate)} – {formatDate(project.endDate)} ); case "budget": return (
{formatMoney(project.budgetCents)}
); case "allocations": return ( {project.totalPersonDays > 0 ? ( {project.totalPersonDays}d ) : ( )} ); case "shoring": return ( ); case "responsible": return —; default: return —; } } // ─── 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 ( ); } return ( {col.label} ); } return (

Projects

{!isLoading && (

{projects.length} project{projects.length !== 1 ? "s" : ""} {hasNextPage ? "+" : ""}

)}
setSearch(e.target.value)} className="app-input max-w-xs" /> {isCustomOrder && ( )} {chips.length > 0 && (
)}
{isLoading ? (
Loading projects…
) : ( <>
{/* Drag handle column */} {visibleColumns.map(renderHeader)} {projects.map((project, index) => { const isSelected = selection.selectedIds.has(project.id); return ( 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` }} > {visibleColumns.map((col) => renderCell(col, project))} ); })}
{ if (el) el.indeterminate = selection.isIndeterminate(projectIds); }} onChange={() => selection.toggleAll(projectIds)} className="rounded border-gray-300 dark:border-gray-600" /> Actions
selection.toggle(project.id)} className="rounded border-gray-300 dark:border-gray-600" />
View →
{projects.length === 0 && (
No projects found.{" "}
)} )}
{/* Batch Status Picker */} {batchStatusPicker && (
setBatchStatusPicker(false)}>
e.stopPropagation()}>

Set status for {selection.count} projects

{ALL_STATUSES.map((s) => ( ))}
)} {/* Confirm batch status change */} {confirmBatchStatus && ( { if (confirmBatchStatus) { batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never }); } setConfirmBatchStatus(null); }} onCancel={() => setConfirmBatchStatus(null)} /> )} {/* Confirm batch delete */} {confirmBatchDelete && ( { batchDeleteMutation.mutate({ ids: confirmBatchDelete }); setConfirmBatchDelete(null); }} onCancel={() => setConfirmBatchDelete(null)} /> )} {/* Batch Action Bar */} setBatchStatusPicker(true) }, { label: `Delete (${selection.count})`, variant: "danger" as const, onClick: () => setConfirmBatchDelete(selection.selectedArray), disabled: batchDeleteMutation.isPending, }, ]} /> {/* Modal */} {modalOpen && } {/* Wizard */} setWizardOpen(false)} />
); }