"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
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 [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(null);
const [successToast, setSuccessToast] = 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() {
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;
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
.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 ? "+" : ""}
)}
setSearchInput(e.target.value)}
className="app-input max-w-xs"
/>
{isCustomOrder && (
)}
{chips.length > 0 && (
)}
{isLoading ? (
Loading projects…
) : (
<>
{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 && (
setSuccessToast(
editingProject
? `Project "${name}" updated successfully.`
: `Project "${name}" created successfully.`,
)
}
/>
)}
{/* Wizard */}
setWizardOpen(false)}
onSuccess={(shortCode, name) =>
setSuccessToast(`Project "${shortCode} — ${name}" created successfully.`)
}
/>
setSuccessToast(null)}
/>
);
}