82acc56b8d
- 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>
988 lines
36 KiB
TypeScript
988 lines
36 KiB
TypeScript
"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>
|
||
);
|
||
}
|