chore: add pre-commit hooks, tighten ESLint, activate Sentry DSN, publish CI coverage (Phase 1)
- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files - Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error - Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin - Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments - Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example - Add coverage artifact upload step to CI test job - Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,8 @@ 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 } from "@capakraken/shared";
|
||||
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
||||
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";
|
||||
@@ -32,7 +32,10 @@ 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";
|
||||
import {
|
||||
PROJECT_STATUS_BADGE as STATUS_COLORS,
|
||||
ORDER_TYPE_BADGE as ORDER_TYPE_COLORS,
|
||||
} from "~/lib/status-styles.js";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,7 +56,13 @@ const ALL_ORDER_TYPES = [
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
|
||||
function BudgetBar({
|
||||
utilizationPercent,
|
||||
budgetCents,
|
||||
}: {
|
||||
utilizationPercent: number;
|
||||
budgetCents: number;
|
||||
}) {
|
||||
if (budgetCents === 0) {
|
||||
return <div className="text-xs text-gray-400">No budget</div>;
|
||||
}
|
||||
@@ -66,14 +75,27 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
|
||||
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
|
||||
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 }) {
|
||||
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);
|
||||
@@ -110,7 +132,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
|
||||
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",
|
||||
@@ -118,40 +143,54 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
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">
|
||||
<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,
|
||||
)}
|
||||
{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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -190,13 +229,12 @@ export function ProjectsClient() {
|
||||
// Flush debounced input to URL
|
||||
useEffect(() => {
|
||||
setFilters({ search: debouncedSearch });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// 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);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.search]);
|
||||
|
||||
const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]);
|
||||
@@ -207,7 +245,10 @@ export function ProjectsClient() {
|
||||
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 [confirmBatchStatus, setConfirmBatchStatus] = useState<{
|
||||
ids: string[];
|
||||
status: string;
|
||||
} | null>(null);
|
||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
@@ -229,7 +270,9 @@ export function ProjectsClient() {
|
||||
});
|
||||
|
||||
// ─── Favorites ──────────────────────────────────────────────────────────
|
||||
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
|
||||
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 }) => {
|
||||
@@ -300,7 +343,7 @@ export function ProjectsClient() {
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
|
||||
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
|
||||
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
|
||||
{
|
||||
search: search || undefined,
|
||||
@@ -308,9 +351,12 @@ export function ProjectsClient() {
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
|
||||
getNextPageParam: (lastPage: { nextCursor?: string | null }) =>
|
||||
lastPage.nextCursor ?? undefined,
|
||||
initialCursor: undefined,
|
||||
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
|
||||
placeholderData: (
|
||||
prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined,
|
||||
) => prev,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
) as {
|
||||
@@ -332,7 +378,8 @@ export function ProjectsClient() {
|
||||
|
||||
// Client-side orderType filter
|
||||
const filteredProjects = useMemo(
|
||||
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
|
||||
() =>
|
||||
orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects,
|
||||
[allProjects, orderTypeFilter],
|
||||
);
|
||||
|
||||
@@ -345,29 +392,41 @@ export function ProjectsClient() {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
|
||||
sorted,
|
||||
viewPrefs,
|
||||
sortField,
|
||||
reset,
|
||||
);
|
||||
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
|
||||
// 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: "" }); }
|
||||
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));
|
||||
@@ -389,9 +448,23 @@ export function ProjectsClient() {
|
||||
}, [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("") }] : []),
|
||||
...(search
|
||||
? [
|
||||
{
|
||||
label: `Search: "${search}"`,
|
||||
onRemove: () => {
|
||||
setSearchInput("");
|
||||
setFilters({ search: "" });
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(statusFilter
|
||||
? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }]
|
||||
: []),
|
||||
...(orderTypeFilter
|
||||
? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }]
|
||||
: []),
|
||||
];
|
||||
|
||||
// ─── Cell renderer ────────────────────────────────────────────────────────
|
||||
@@ -401,18 +474,42 @@ export function ProjectsClient() {
|
||||
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>;
|
||||
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>;
|
||||
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">
|
||||
<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:")} />
|
||||
<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"
|
||||
@@ -421,7 +518,13 @@ export function ProjectsClient() {
|
||||
color: project.color ?? "#6366f1",
|
||||
}}
|
||||
>
|
||||
{project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()}
|
||||
{project.name
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{project.name}</span>
|
||||
@@ -442,14 +545,19 @@ export function ProjectsClient() {
|
||||
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] ?? ""}`}>
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
@@ -459,7 +567,10 @@ export function ProjectsClient() {
|
||||
<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} />
|
||||
<BudgetBar
|
||||
utilizationPercent={project.utilizationPercent ?? 0}
|
||||
budgetCents={project.budgetCents}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "allocations":
|
||||
@@ -467,8 +578,18 @@ export function ProjectsClient() {
|
||||
<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
|
||||
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>
|
||||
@@ -484,14 +605,30 @@ export function ProjectsClient() {
|
||||
</td>
|
||||
);
|
||||
case "responsible":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">—</td>;
|
||||
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>;
|
||||
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"]);
|
||||
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 (
|
||||
@@ -506,7 +643,10 @@ export function ProjectsClient() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
@@ -531,7 +671,12 @@ export function ProjectsClient() {
|
||||
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" />
|
||||
<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>
|
||||
@@ -541,7 +686,12 @@ export function ProjectsClient() {
|
||||
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" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Quick Add
|
||||
</button>
|
||||
@@ -563,7 +713,9 @@ export function ProjectsClient() {
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
@@ -573,7 +725,9 @@ export function ProjectsClient() {
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{ALL_ORDER_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ColumnTogglePanel
|
||||
@@ -602,7 +756,9 @@ export function ProjectsClient() {
|
||||
|
||||
<div className="app-data-table">
|
||||
{isLoading ? (
|
||||
<div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">Loading projects…</div>
|
||||
<div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">
|
||||
Loading projects…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -644,9 +800,15 @@ export function ProjectsClient() {
|
||||
<td className="px-2 py-3 w-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); toggleFavMutation.mutate({ projectId: project.id }); }}
|
||||
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"}
|
||||
title={
|
||||
favSet.has(project.id) ? "Remove from favorites" : "Add to favorites"
|
||||
}
|
||||
>
|
||||
{favSet.has(project.id) ? "★" : "☆"}
|
||||
</button>
|
||||
@@ -669,7 +831,10 @@ export function ProjectsClient() {
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Link href={`/projects/${project.id}`} className="app-action-edit link-hover-underline">
|
||||
<Link
|
||||
href={`/projects/${project.id}`}
|
||||
className="app-action-edit link-hover-underline"
|
||||
>
|
||||
View →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -684,25 +849,34 @@ export function ProjectsClient() {
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewModal}
|
||||
className="text-brand-600 hover:underline font-medium"
|
||||
>
|
||||
Create your first project.
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={handleFetchNext}
|
||||
isLoading={isFetchingNextPage}
|
||||
/>
|
||||
<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="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
|
||||
@@ -714,7 +888,12 @@ export function ProjectsClient() {
|
||||
}}
|
||||
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])}>
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-block px-2 py-0.5 text-xs rounded-full",
|
||||
STATUS_COLORS[s.value],
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -732,7 +911,10 @@ export function ProjectsClient() {
|
||||
confirmLabel="Update"
|
||||
onConfirm={() => {
|
||||
if (confirmBatchStatus) {
|
||||
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
||||
batchUpdateStatus.mutate({
|
||||
ids: confirmBatchStatus.ids,
|
||||
status: confirmBatchStatus.status as never,
|
||||
});
|
||||
}
|
||||
setConfirmBatchStatus(null);
|
||||
}}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function ResourcesClient() {
|
||||
const departedFilter = resourceUrlFilters.departed as BooleanFilter;
|
||||
// chapters stored as comma-separated string; empty string means "all chapters visible"
|
||||
const chaptersParam = resourceUrlFilters.chapters;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
const chapterFilter: string[] = useMemo(
|
||||
() => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []),
|
||||
[chaptersParam],
|
||||
@@ -175,21 +175,32 @@ export function ResourcesClient() {
|
||||
// Flush debounced search input to URL
|
||||
useEffect(() => {
|
||||
setResourceUrlFilters({ search: debouncedSearch });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Keep local search input in sync when URL changes externally
|
||||
useEffect(() => {
|
||||
setSearchInput(resourceUrlFilters.search);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resourceUrlFilters.search]);
|
||||
|
||||
const setIsActiveFilter = useCallback((v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }), [setResourceUrlFilters]);
|
||||
const setRolledOffFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }), [setResourceUrlFilters]);
|
||||
const setDepartedFilter = useCallback((v: BooleanFilter) => setResourceUrlFilters({ departed: v }), [setResourceUrlFilters]);
|
||||
const setChapterFilter = useCallback((v: string[]) => {
|
||||
setResourceUrlFilters({ chapters: v.join(",") });
|
||||
}, [setResourceUrlFilters]);
|
||||
const setIsActiveFilter = useCallback(
|
||||
(v: ActiveFilter) => setResourceUrlFilters({ activeFilter: v }),
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
const setRolledOffFilter = useCallback(
|
||||
(v: BooleanFilter) => setResourceUrlFilters({ rolledOff: v }),
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
const setDepartedFilter = useCallback(
|
||||
(v: BooleanFilter) => setResourceUrlFilters({ departed: v }),
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
const setChapterFilter = useCallback(
|
||||
(v: string[]) => {
|
||||
setResourceUrlFilters({ chapters: v.join(",") });
|
||||
},
|
||||
[setResourceUrlFilters],
|
||||
);
|
||||
|
||||
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
|
||||
const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]);
|
||||
@@ -412,7 +423,13 @@ export function ResourcesClient() {
|
||||
|
||||
function clearAll() {
|
||||
setSearchInput("");
|
||||
setResourceUrlFilters({ search: "", activeFilter: "active", rolledOff: DEFAULT_BOOLEAN_FILTER, departed: DEFAULT_BOOLEAN_FILTER, chapters: "" });
|
||||
setResourceUrlFilters({
|
||||
search: "",
|
||||
activeFilter: "active",
|
||||
rolledOff: DEFAULT_BOOLEAN_FILTER,
|
||||
departed: DEFAULT_BOOLEAN_FILTER,
|
||||
chapters: "",
|
||||
});
|
||||
setHiddenCountryIds([]);
|
||||
setIncludeWithoutCountry(true);
|
||||
setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]);
|
||||
@@ -468,7 +485,9 @@ export function ResourcesClient() {
|
||||
if (next.length === chapters.length) {
|
||||
setChapterFilter([]);
|
||||
} else {
|
||||
setChapterFilter(next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)));
|
||||
setChapterFilter(
|
||||
next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[chapters, chapterFilter, setChapterFilter],
|
||||
@@ -533,13 +552,23 @@ export function ResourcesClient() {
|
||||
{ header: "LCR (cents)", accessor: (r) => r.lcrCents },
|
||||
{ header: "Currency", accessor: (r) => r.currency },
|
||||
{ header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget },
|
||||
{ header: "Active", accessor: (r) => r.isActive ? "Yes" : "No" },
|
||||
{ header: "Active", accessor: (r) => (r.isActive ? "Yes" : "No") },
|
||||
]);
|
||||
downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
}, [displayedResources, selection.selectedIds]);
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setResourceUrlFilters({ search: "" }); } }] : []),
|
||||
...(search
|
||||
? [
|
||||
{
|
||||
label: `Search: "${search}"`,
|
||||
onRemove: () => {
|
||||
setSearchInput("");
|
||||
setResourceUrlFilters({ search: "" });
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(chapterFilter.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -1303,7 +1332,12 @@ export function ResourcesClient() {
|
||||
/>
|
||||
</div>
|
||||
{isOverflow && (
|
||||
<span className="text-[9px] font-bold text-green-600 dark:text-green-400" title={`${actual}% actual`}>+</span>
|
||||
<span
|
||||
className="text-[9px] font-bold text-green-600 dark:text-green-400"
|
||||
title={`${actual}% actual`}
|
||||
>
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user