Files
Nexus/apps/web/src/app/(app)/resources/ResourcesClient.tsx
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

1516 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import Link from "next/link";
import type { Resource, SkillEntry } from "@nexus/shared";
import { RESOURCE_COLUMNS } from "@nexus/shared";
import { BlueprintTarget, ResourceType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatMoney } from "~/lib/format.js";
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
import dynamic from "next/dynamic";
import { ResourceModal } from "~/components/resources/ResourceModal.js";
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
const ImportModal = dynamic(
() => import("~/components/resources/ImportModal.js").then((mod) => mod.ImportModal),
{ ssr: false },
);
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 { CustomFieldFilterBar } from "~/components/ui/CustomFieldFilterBar.js";
import { useFilters } from "~/hooks/useFilters.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
import {
type ModalState,
type ConfirmState,
type ActiveFilter,
type BooleanFilter,
type ResourceListPage,
type CountryOption,
DEFAULT_HIDDEN_RESOURCE_TYPES,
DEFAULT_BOOLEAN_FILTER,
RESOURCE_TYPE_LABELS,
} from "./resource-client/types.js";
import { FilterDropdown } from "./resource-client/FilterDropdown.js";
import { BooleanBadge } from "./resource-client/BooleanBadge.js";
export function ResourcesClient() {
const [resourceUrlFilters, setResourceUrlFilters] = useUrlFilters({
search: "",
activeFilter: "active" as string,
rolledOff: DEFAULT_BOOLEAN_FILTER as string,
departed: DEFAULT_BOOLEAN_FILTER as string,
chapters: "",
});
// Debounced local state for search text input
const [searchInput, setSearchInput] = useState(resourceUrlFilters.search);
const debouncedSearch = useDebounce(searchInput, 300);
const search = resourceUrlFilters.search;
const isActiveFilter = resourceUrlFilters.activeFilter as ActiveFilter;
const rolledOffFilter = resourceUrlFilters.rolledOff as BooleanFilter;
const departedFilter = resourceUrlFilters.departed as BooleanFilter;
// chapters stored as comma-separated string; empty string means "all chapters visible"
const chaptersParam = resourceUrlFilters.chapters;
const chapterFilter: string[] = useMemo(
() => (chaptersParam ? chaptersParam.split(",").filter(Boolean) : []),
[chaptersParam],
);
// Flush debounced search input to URL
useEffect(() => {
setResourceUrlFilters({ search: debouncedSearch });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
// Keep local search input in sync when URL changes externally
useEffect(() => {
setSearchInput(resourceUrlFilters.search);
}, [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 [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
const [hiddenCountryIds, setHiddenCountryIds] = useState<string[]>([]);
const [includeWithoutCountry, setIncludeWithoutCountry] = useState(true);
const [hiddenResourceTypes, setHiddenResourceTypes] = useState<ResourceType[]>([
...DEFAULT_HIDDEN_RESOURCE_TYPES,
]);
const [includeWithoutResourceType, setIncludeWithoutResourceType] = useState(true);
const [modal, setModal] = useState<ModalState>({ type: "closed" });
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
const [successToast, setSuccessToast] = useState<string | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewScores, canViewCosts, canManageUsers } = usePermissions();
const {
customFieldFilters,
setCustomFieldFilter,
clearFilters: clearCustomFilters,
} = useFilters();
// ─── Custom field columns from global blueprints ──────────────────────────
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
{ target: BlueprintTarget.RESOURCE },
{ 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],
);
const filterableFields = useMemo(
() => (globalFieldDefs ?? []).filter((f) => f.isFilterable),
[globalFieldDefs],
);
// ─── Column visibility ────────────────────────────────────────────────────
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
"resources",
RESOURCE_COLUMNS,
customColumns,
);
const defaultKeys = useMemo(
() => RESOURCE_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key),
[],
);
// ─── Infinite query (cursor-based) ────────────────────────────────────────
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
} = (trpc.resource.listStaff.useInfiniteQuery as any)(
{
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
search: search || undefined,
chapters: chapterFilter.length > 0 ? chapterFilter : undefined,
excludedCountryIds: hiddenCountryIds,
includeWithoutCountry,
excludedResourceTypes: hiddenResourceTypes,
includeWithoutResourceType,
rolledOff: rolledOffFilter === "all" ? undefined : rolledOffFilter === "yes",
departed: departedFilter === "all" ? undefined : departedFilter === "yes",
includeRoles: true,
limit: 50,
...(customFieldFilters.length > 0 ? { customFieldFilters } : {}),
},
{
getNextPageParam: (lastPage: ResourceListPage) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev: { pages: ResourceListPage[] } | undefined) => prev,
staleTime: 20_000,
},
) as {
data:
| {
pages: ResourceListPage[];
}
| undefined;
isLoading: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
hasNextPage: boolean | undefined;
};
const resources = useMemo(
() => (data?.pages.flatMap((p) => p.resources) ?? []) as unknown as Resource[],
[data],
);
const total = data?.pages[0]?.total ?? 0;
// ─── Sort + row order (per-user persistence) ──────────────────────────────
const viewPrefs = useViewPrefs("resources");
const { sorted, sortField, sortDir, toggle, reset } = useTableSort<Resource>(resources, {
initialField: viewPrefs.savedSort?.field ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const {
orderedRows: displayedResources,
reorder,
isCustomOrder,
resetOrder,
} = useRowOrder(sorted, viewPrefs, sortField, reset);
const rowDragRef = useRef<string | null>(null);
const resourceIds: string[] = displayedResources.map((r) => r.id);
// Performance note: cursor-based infinite scroll (50 rows/page) keeps DOM nodes bounded.
// True virtualizer is not needed for typical resource counts (<500).
// ─── Chargeability stats ──────────────────────────────────────────────────
const { data: chargeabilityData } = trpc.resource.getChargeabilityStats.useQuery(
{ includeProposed: includeProposedChargeability },
{ enabled: canViewCosts, placeholderData: (prev) => prev, staleTime: 60_000 },
);
const chargeabilityMap = useMemo(
() => new Map((chargeabilityData ?? []).map((s) => [s.id, s])),
[chargeabilityData],
);
// ─── Chapters filter ──────────────────────────────────────────────────────
const { data: chapterData } = trpc.resource.chapters.useQuery(undefined, {
placeholderData: (prev) => prev,
staleTime: 60_000,
});
const chapters = chapterData ?? [];
const { data: countriesData } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const countries = useMemo(
() =>
((countriesData ?? []) as Array<{ id: string; name: string }>).map((country) => ({
id: country.id,
name: country.name,
})),
[countriesData],
) as CountryOption[];
const allCountryIds = useMemo(() => countries.map((country) => country.id), [countries]);
const isAllCountriesVisible = hiddenCountryIds.length === 0;
const allResourceTypes = useMemo(() => Object.values(ResourceType), []);
const isAllResourceTypesVisible = hiddenResourceTypes.length === 0;
const isDefaultResourceTypeVisibility =
hiddenResourceTypes.length === DEFAULT_HIDDEN_RESOURCE_TYPES.length &&
DEFAULT_HIDDEN_RESOURCE_TYPES.every((resourceType) =>
hiddenResourceTypes.includes(resourceType),
);
// ─── Mutations ────────────────────────────────────────────────────────────
const deactivateMutation = trpc.resource.deactivate.useMutation({
onSuccess: () => {
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
},
});
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
onSuccess: () => {
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
selection.clear();
},
});
const hardDeleteMutation = trpc.resource.hardDelete.useMutation({
onSuccess: () => {
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
},
});
const batchHardDeleteMutation = trpc.resource.batchHardDelete.useMutation({
onSuccess: () => {
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
selection.clear();
},
});
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
search,
chapterFilter,
isActiveFilter,
hiddenCountryIds,
includeWithoutCountry,
hiddenResourceTypes,
includeWithoutResourceType,
rolledOffFilter,
departedFilter,
]);
function closeModal() {
setModal({ type: "closed" });
}
function handleConfirm() {
if (confirm.type === "deactivate") {
deactivateMutation.mutate({ id: confirm.resource.id });
} else if (confirm.type === "batchDeactivate") {
batchDeactivateMutation.mutate({ ids: confirm.ids });
} else if (confirm.type === "delete") {
hardDeleteMutation.mutate({ id: confirm.resource.id });
} else if (confirm.type === "batchDelete") {
batchHardDeleteMutation.mutate({ ids: confirm.ids });
}
setConfirm({ type: "closed" });
}
function clearAll() {
setSearchInput("");
setResourceUrlFilters({
search: "",
activeFilter: "active",
rolledOff: DEFAULT_BOOLEAN_FILTER,
departed: DEFAULT_BOOLEAN_FILTER,
chapters: "",
});
setHiddenCountryIds([]);
setIncludeWithoutCountry(true);
setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]);
setIncludeWithoutResourceType(true);
clearCustomFilters();
}
const handleFetchNext = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const toggleCountry = useCallback(
(countryId: string) => {
setHiddenCountryIds((current) =>
current.includes(countryId)
? current.filter((id) => id !== countryId)
: [...current, countryId].sort((left, right) => {
const leftIndex = allCountryIds.indexOf(left);
const rightIndex = allCountryIds.indexOf(right);
return leftIndex - rightIndex;
}),
);
},
[allCountryIds],
);
const toggleAllCountries = useCallback(() => {
setHiddenCountryIds((current) => (current.length === 0 ? allCountryIds : []));
}, [allCountryIds]);
const toggleResourceType = useCallback((resourceType: ResourceType) => {
setHiddenResourceTypes((current) => {
if (current.includes(resourceType)) {
return current.filter((value) => value !== resourceType);
}
return [...current, resourceType].sort(
(left, right) =>
Object.values(ResourceType).indexOf(left) - Object.values(ResourceType).indexOf(right),
);
});
}, []);
const toggleAllResourceTypes = useCallback(() => {
setHiddenResourceTypes((current) => (current.length === 0 ? allResourceTypes : []));
}, [allResourceTypes]);
const toggleChapter = useCallback(
(chapter: string) => {
const currentSelection = chapterFilter.length === 0 ? [...chapters] : chapterFilter;
const next = currentSelection.includes(chapter)
? currentSelection.filter((value) => value !== chapter)
: [...currentSelection, chapter];
if (next.length === chapters.length) {
setChapterFilter([]);
} else {
setChapterFilter(
next.sort((left, right) => chapters.indexOf(left) - chapters.indexOf(right)),
);
}
},
[chapters, chapterFilter, setChapterFilter],
);
const visibleCountryCount = countries.length - hiddenCountryIds.length;
const countryFilterLabel = useMemo(() => {
if (countries.length === 0) return "Countries";
if (hiddenCountryIds.length === 0 && includeWithoutCountry)
return `Countries: all (${countries.length})`;
if (visibleCountryCount === 0 && !includeWithoutCountry) return "Countries: none";
const suffix = includeWithoutCountry ? " + unspecified" : "";
return `Countries: ${visibleCountryCount}/${countries.length}${suffix}`;
}, [countries.length, hiddenCountryIds.length, includeWithoutCountry, visibleCountryCount]);
const visibleResourceTypeCount = allResourceTypes.length - hiddenResourceTypes.length;
const resourceTypeFilterLabel = useMemo(() => {
if (hiddenResourceTypes.length === 0 && includeWithoutResourceType) {
return `Types: all (${allResourceTypes.length})`;
}
if (visibleResourceTypeCount === 0 && !includeWithoutResourceType) {
return "Types: none";
}
const suffix = includeWithoutResourceType ? " + unspecified" : "";
return `Types: ${visibleResourceTypeCount}/${allResourceTypes.length}${suffix}`;
}, [
allResourceTypes.length,
hiddenResourceTypes.length,
includeWithoutResourceType,
visibleResourceTypeCount,
]);
const chapterFilterLabel = useMemo(() => {
if (chapters.length === 0 || chapterFilter.length === 0) {
return chapters.length > 0 ? `Chapter: all (${chapters.length})` : "Chapter: all";
}
return `Chapter: ${chapterFilter.length}/${chapters.length}`;
}, [chapterFilter.length, chapters.length]);
const activeFilterLabel = useMemo(() => {
if (isActiveFilter === "inactive") return "Status: inactive";
if (isActiveFilter === "all") return "Status: all";
return "Status: active";
}, [isActiveFilter]);
const rolledOffFilterLabel = useMemo(() => {
if (rolledOffFilter === "yes") return "Rolled Off: yes";
if (rolledOffFilter === "all") return "Rolled Off: all";
return "Rolled Off: no";
}, [rolledOffFilter]);
const departedFilterLabel = useMemo(() => {
if (departedFilter === "yes") return "Departed: yes";
if (departedFilter === "all") return "Departed: all";
return "Departed: no";
}, [departedFilter]);
const exportSelectedCsv = useCallback(() => {
const selected = displayedResources.filter((r) => selection.selectedIds.has(r.id));
if (selected.length === 0) return;
const csv = generateCsv(selected, [
{ header: "EID", accessor: (r) => r.eid },
{ header: "Name", accessor: (r) => r.displayName },
{ header: "Email", accessor: (r) => r.email },
{ header: "Chapter", accessor: (r) => r.chapter ?? "" },
{ 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") },
]);
downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`);
}, [displayedResources, selection.selectedIds]);
const chips = [
...(search
? [
{
label: `Search: "${search}"`,
onRemove: () => {
setSearchInput("");
setResourceUrlFilters({ search: "" });
},
},
]
: []),
...(chapterFilter.length > 0
? [
{
label: `Chapters: ${chapterFilter.length}/${chapters.length}`,
onRemove: () => setChapterFilter([]),
},
]
: []),
...(isActiveFilter !== "active"
? [
{
label: isActiveFilter === "all" ? "Showing all" : "Inactive only",
onRemove: () => setIsActiveFilter("active"),
},
]
: []),
...(hiddenCountryIds.length > 0
? [
{
label: `Hidden countries: ${hiddenCountryIds.length}`,
onRemove: () => setHiddenCountryIds([]),
},
]
: []),
...(!includeWithoutCountry
? [{ label: "No country hidden", onRemove: () => setIncludeWithoutCountry(true) }]
: []),
...(!isDefaultResourceTypeVisibility
? [
{
label:
hiddenResourceTypes.length === 0
? "Hidden types: none"
: `Hidden types: ${hiddenResourceTypes.map((type) => RESOURCE_TYPE_LABELS[type]).join(", ")}`,
onRemove: () => setHiddenResourceTypes([...DEFAULT_HIDDEN_RESOURCE_TYPES]),
},
]
: []),
...(!includeWithoutResourceType
? [{ label: "Unspecified type hidden", onRemove: () => setIncludeWithoutResourceType(true) }]
: []),
...(rolledOffFilter !== DEFAULT_BOOLEAN_FILTER
? [
{
label: `Rolled Off: ${rolledOffFilter === "yes" ? "Yes" : "No"}`,
onRemove: () => setRolledOffFilter(DEFAULT_BOOLEAN_FILTER),
},
]
: []),
...(departedFilter !== DEFAULT_BOOLEAN_FILTER
? [
{
label: `Departed: ${departedFilter === "yes" ? "Yes" : "No"}`,
onRemove: () => setDepartedFilter(DEFAULT_BOOLEAN_FILTER),
},
]
: []),
...customFieldFilters.map((f) => ({
label: `${f.key}: ${f.value}`,
onRemove: () => setCustomFieldFilter(f.key, "", f.type),
})),
];
return (
<div className="app-page space-y-5 pb-24">
<div className="app-page-header gap-4">
<div>
<h1 className="app-page-title">Resources</h1>
{!isLoading && (
<p className="app-page-subtitle mt-1">
{total} resource{total !== 1 ? "s" : ""}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setModal({ type: "import" })}
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"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
Import
</button>
<button
type="button"
onClick={() => setModal({ type: "create" })}
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"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
New Resource
</button>
</div>
</div>
{/* Filters + Column toggle */}
<FilterBar>
<div className="flex items-center gap-2">
<input
type="search"
placeholder="Search by name, EID, email..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="app-input max-w-xs"
/>
<InfoTooltip
content="Searches the loaded resource list by display name, EID, and email. Combine it with the dropdown filters below."
width="w-72"
/>
</div>
{chapters.length > 0 && (
<FilterDropdown
label={chapterFilterLabel}
widthClassName="w-80"
buttonClassName="min-w-52"
tooltipContent="Multi-select visibility filter. Checked chapters stay visible. 'All Chapters' resets the filter and shows every chapter."
>
<div className="mb-3">
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Chapter</h2>
<p className="text-xs text-gray-500">Checked chapters stay visible.</p>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
<label className="flex items-center gap-3 border-b border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:text-gray-200">
<input
type="checkbox"
checked={chapterFilter.length === 0}
onChange={() => setChapterFilter([])}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="font-medium">All Chapters</span>
</label>
<div className="max-h-64 overflow-auto">
{chapters.map((chapter) => (
<label
key={chapter}
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={chapterFilter.length === 0 || chapterFilter.includes(chapter)}
onChange={() => toggleChapter(chapter)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span>{chapter}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
)}
<FilterDropdown
label={activeFilterLabel}
widthClassName="w-64"
buttonClassName="min-w-44"
tooltipContent="Single-choice filter for the resource record state. 'Active' hides inactive records. 'All' shows both."
>
<div className="mb-3">
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Resource Status
</h2>
<p className="text-xs text-gray-500">Select one visibility mode.</p>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
{[
{ value: "active", label: "Active only" },
{ value: "inactive", label: "Inactive only" },
{ value: "all", label: "All resources" },
].map((option, index) => (
<label
key={option.value}
className={`flex items-center gap-3 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800 ${index < 2 ? "border-b border-gray-100 dark:border-gray-800" : ""}`}
>
<input
type="checkbox"
checked={isActiveFilter === option.value}
onChange={() => setIsActiveFilter(option.value as ActiveFilter)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span>{option.label}</span>
</label>
))}
</div>
</FilterDropdown>
<FilterDropdown
label={rolledOffFilterLabel}
widthClassName="w-64"
buttonClassName="min-w-44"
tooltipContent="Single-choice filter for the explicit rolled-off flag on the resource. Default is 'No', so rolled-off resources are hidden unless you change it."
>
<div className="mb-3">
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Rolled Off</h2>
<p className="text-xs text-gray-500">Select one filter value.</p>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
{[
{ value: "all", label: "All" },
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
].map((option, index) => (
<label
key={option.value}
className={`flex items-center gap-3 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800 ${index < 2 ? "border-b border-gray-100 dark:border-gray-800" : ""}`}
>
<input
type="checkbox"
checked={rolledOffFilter === option.value}
onChange={() => setRolledOffFilter(option.value as BooleanFilter)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span>{option.label}</span>
</label>
))}
</div>
</FilterDropdown>
<FilterDropdown
label={departedFilterLabel}
widthClassName="w-64"
buttonClassName="min-w-44"
tooltipContent="Single-choice filter for the departed flag. Default is 'No', so departed resources are hidden from the list until you switch this to 'All' or 'Yes'."
>
<div className="mb-3">
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Departed</h2>
<p className="text-xs text-gray-500">Select one filter value.</p>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
{[
{ value: "all", label: "All" },
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
].map((option, index) => (
<label
key={option.value}
className={`flex items-center gap-3 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800 ${index < 2 ? "border-b border-gray-100 dark:border-gray-800" : ""}`}
>
<input
type="checkbox"
checked={departedFilter === option.value}
onChange={() => setDepartedFilter(option.value as BooleanFilter)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span>{option.label}</span>
</label>
))}
</div>
</FilterDropdown>
{canViewCosts && (
<label className="flex items-center gap-2 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
<input
type="checkbox"
checked={includeProposedChargeability}
onChange={(e) => setIncludeProposedChargeability(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Include proposed in chargeability
<InfoTooltip
content="Only affects chargeability numbers shown on this page. When enabled, proposed assignments and imported TBD planning are counted alongside confirmed work."
width="w-72"
/>
</label>
)}
<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>
<div className="app-toolbar mb-3 flex flex-wrap items-start gap-3">
<FilterDropdown
label={countryFilterLabel}
widthClassName="w-80"
tooltipContent="Multi-select visibility filter. Checked countries stay visible. Uncheck a country to hide it while leaving all other countries visible."
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Countries</h2>
<p className="text-xs text-gray-500">Checked countries stay visible.</p>
</div>
<div className="flex items-center gap-3 text-xs font-medium">
<button
type="button"
onClick={() => setHiddenCountryIds([])}
className="text-brand-600 hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
>
Show all
</button>
<button
type="button"
onClick={toggleAllCountries}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
>
{isAllCountriesVisible ? "Hide all" : "Toggle all"}
</button>
</div>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
<label className="flex items-center gap-3 border-b border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:text-gray-200">
<input
type="checkbox"
checked={includeWithoutCountry}
onChange={(e) => setIncludeWithoutCountry(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="font-medium">Unspecified country</span>
</label>
<div className="max-h-64 overflow-auto">
{countries.map((country) => (
<label
key={country.id}
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={!hiddenCountryIds.includes(country.id)}
onChange={() => toggleCountry(country.id)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span>{country.name}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
<FilterDropdown
label={resourceTypeFilterLabel}
widthClassName="w-80"
tooltipContent="Multi-select visibility filter for stored resource types. Checked types stay visible. Freelancers are hidden by default."
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Resource Types
</h2>
<p className="text-xs text-gray-500">Checked types stay visible.</p>
</div>
<div className="flex items-center gap-3 text-xs font-medium">
<button
type="button"
onClick={() => setHiddenResourceTypes([])}
className="text-brand-600 hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
>
Show all
</button>
<button
type="button"
onClick={toggleAllResourceTypes}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
>
{isAllResourceTypesVisible ? "Hide all" : "Toggle all"}
</button>
</div>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
<label className="flex items-center gap-3 border-b border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:text-gray-200">
<input
type="checkbox"
checked={includeWithoutResourceType}
onChange={(e) => setIncludeWithoutResourceType(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="font-medium">Unspecified type</span>
</label>
<div>
{allResourceTypes.map((resourceType) => (
<label
key={resourceType}
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={!hiddenResourceTypes.includes(resourceType)}
onChange={() => toggleResourceType(resourceType)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span>{RESOURCE_TYPE_LABELS[resourceType]}</span>
</label>
))}
</div>
</div>
</FilterDropdown>
</div>
{filterableFields.length > 0 && (
<div className="mb-2">
<CustomFieldFilterBar
filterableFields={filterableFields}
activeFilters={customFieldFilters}
onSetFilter={setCustomFieldFilter}
/>
</div>
)}
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{/* Table */}
<div className="app-data-table">
{isLoading && resources.length === 0 ? (
<div className="p-12 text-center text-sm text-gray-500 shimmer-skeleton">
Loading resources
</div>
) : (
<>
<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="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(resourceIds)}
ref={(el) => {
if (el) el.indeterminate = selection.isIndeterminate(resourceIds);
}}
onChange={() => selection.toggleAll(resourceIds)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</th>
{visibleColumns.map((col) => {
if (col.isCustom) {
return (
<th
key={col.key}
className="px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
{col.label}
</th>
);
}
switch (col.key) {
case "eid":
return (
<SortableColumnHeader
key={col.key}
label="EID"
field="eid"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Unique employee identifier used across all Nexus records."
/>
);
case "displayName":
return (
<SortableColumnHeader
key={col.key}
label="Name / Email"
field="displayName"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
);
case "chapter":
return (
<SortableColumnHeader
key={col.key}
label="Chapter"
field="chapter"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
);
case "lcr":
return (
<SortableColumnHeader
key={col.key}
label="LCR (€/h)"
field="lcrCents"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Labour Cost Rate — the resource's hourly cost in EUR. Used to calculate project budgets (LCR × hours/day × working days)."
/>
);
case "chargeability":
return (
<SortableColumnHeader
key={col.key}
label="Chargeability (actual)"
field="chargeabilityTarget"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Actual = CONFIRMED+ACTIVE bookings on ACTIVE projects ÷ available hours this month. Enable the page toggle to include PROPOSED work. Expected (in parentheses) includes all non-cancelled bookings."
tooltipWidth="w-80"
/>
);
case "valueScore":
return canViewScores ? (
<SortableColumnHeader
key={col.key}
label="Score"
field="valueScore"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Composite price/quality score 0100. Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%. Recompute in Admin → Settings."
tooltipWidth="w-72"
/>
) : null;
case "roles":
return (
<th
key={col.key}
className="px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
Roles{" "}
<InfoTooltip content="Primary role (★) and additional roles assigned to this resource. Used for open demand and staffing suggestions." />
</th>
);
case "rolledOff":
return (
<th
key={col.key}
className="px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
Rolled Off
</th>
);
case "departed":
return (
<th
key={col.key}
className="px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
Departed
</th>
);
case "isActive":
return (
<th
key={col.key}
className="px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
Skills{" "}
<InfoTooltip content="Skills from the resource's skill matrix. Shows first 3; hover the +N badge for more." />
</th>
);
default:
return (
<th
key={col.key}
className="px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
{col.label}
</th>
);
}
})}
<th className="px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{displayedResources.map((resource, index) => {
const skills = resource.skills as unknown as SkillEntry[];
const isSelected = selection.selectedIds.has(resource.id);
const isDeactivating =
deactivateMutation.isPending &&
(deactivateMutation.variables as { id: string } | undefined)?.id ===
resource.id;
const dynFields =
(resource as unknown as { dynamicFields?: Record<string, unknown> })
.dynamicFields ?? {};
return (
<DraggableTableRow
key={resource.id}
id={resource.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, resource.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-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(resource.id)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
{visibleColumns.map((col) => {
if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey];
return (
<td
key={col.key}
className="px-3 py-3 text-sm text-gray-700 dark:text-gray-200"
>
{val != null ? String(val) : "—"}
</td>
);
}
switch (col.key) {
case "eid":
return (
<td
key={col.key}
className="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-300"
>
{resource.eid}
</td>
);
case "displayName": {
const initials = resource.displayName
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
const rr =
(
resource as unknown as {
resourceRoles?: {
isPrimary: boolean;
role: { color: string | null };
}[];
}
).resourceRoles ?? [];
const primaryRole = rr.find((r) => r.isPrimary);
const avatarColor =
primaryRole?.role.color ??
`hsl(${[...resource.displayName].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360}, 55%, 45%)`;
return (
<td key={col.key} className="px-4 py-3">
<Link
href={`/resources/${resource.id}`}
className="inline-flex items-center gap-2.5 transition-colors hover:text-brand-600 group"
>
<span
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundColor: avatarColor }}
>
{initials}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-gray-900 group-hover:text-brand-600 group-hover:underline dark:text-gray-100">
{resource.displayName}
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
{resource.email}
</span>
</span>
</Link>
</td>
);
}
case "chapter":
return (
<td
key={col.key}
className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"
>
{resource.chapter ?? "—"}
</td>
);
case "lcr":
return (
<td
key={col.key}
className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100"
>
{formatMoney(resource.lcrCents, resource.currency)}
</td>
);
case "chargeability": {
if (!canViewCosts)
return (
<td
key={col.key}
className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{resource.chargeabilityTarget}%
</td>
);
const stats = chargeabilityMap.get(resource.id);
const actual = stats?.actualChargeability;
const expected = stats?.expectedChargeability;
const target = resource.chargeabilityTarget;
const color =
actual == null
? "text-gray-400"
: actual >= target
? "text-green-700 dark:text-green-300"
: actual >= target - 20
? "text-amber-600 dark:text-amber-300"
: "text-red-600 dark:text-red-300";
// Bar color based on % of target achieved
const barRatio = actual != null && target > 0 ? actual / target : 0;
const barColor =
actual == null
? "bg-gray-300 dark:bg-gray-600"
: barRatio >= 0.8
? "bg-green-500"
: barRatio >= 0.5
? "bg-amber-500"
: "bg-red-500";
const barWidth = actual != null ? Math.min(actual, 100) : 0;
const isOverflow = actual != null && actual > 100;
return (
<td key={col.key} className="px-4 py-3 text-sm min-w-[120px]">
<div>
<span className={`font-medium ${color}`}>
{actual != null ? `${actual}%` : "—"}
</span>
{expected != null && expected !== actual && (
<span className="ml-1 text-xs text-gray-400">
({expected}% exp.)
</span>
)}
{actual !== target && (
<div className="text-xs text-gray-400">Target: {target}%</div>
)}
{actual != null && (
<div className="mt-1 flex items-center gap-1">
<div className="h-[3px] flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${barColor}`}
style={{ width: `${barWidth}%` }}
/>
</div>
{isOverflow && (
<span
className="text-[9px] font-bold text-green-600 dark:text-green-400"
title={`${actual}% actual`}
>
+
</span>
)}
</div>
)}
</div>
</td>
);
}
case "valueScore": {
if (!canViewScores) return null;
const score = (resource as unknown as { valueScore?: number | null })
.valueScore;
return (
<td key={col.key} className="px-4 py-3 text-sm">
{score != null ? (
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${score >= 70 ? "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-300" : score >= 40 ? "bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300" : "bg-red-100 text-red-700 dark:bg-red-950/30 dark:text-red-300"}`}
>
{score}
</span>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
);
}
case "roles": {
const rr =
(
resource as unknown as {
resourceRoles?: {
isPrimary: boolean;
role: { id: string; name: string; color: string | null };
}[];
}
).resourceRoles ?? [];
return (
<td key={col.key} className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{rr.map((r) => (
<span
key={r.role.id}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${r.role.color ?? "#6366f1"}22`,
color: r.role.color ?? "#6366f1",
}}
>
{r.isPrimary && <span className="text-[10px]"></span>}
{r.role.name}
</span>
))}
{rr.length === 0 && (
<span className="text-xs text-gray-400"></span>
)}
</div>
</td>
);
}
case "rolledOff":
return (
<td key={col.key} className="px-4 py-3 text-sm">
<BooleanBadge
value={
(resource as unknown as { rolledOff?: boolean }).rolledOff ??
false
}
/>
</td>
);
case "departed":
return (
<td key={col.key} className="px-4 py-3 text-sm">
<BooleanBadge
value={
(resource as unknown as { departed?: boolean }).departed ??
false
}
/>
</td>
);
case "isActive":
return (
<td key={col.key} className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{skills.slice(0, 3).map((s) => (
<span
key={s.skill}
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-900/60 dark:text-brand-100"
>
{s.skill}
</span>
))}
{skills.length > 3 && (
<span className="text-xs text-gray-400">
+{skills.length - 3}
</span>
)}
</div>
</td>
);
default:
return (
<td
key={col.key}
className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"
>
</td>
);
}
})}
<td className="px-4 py-3 text-right whitespace-nowrap">
<button
type="button"
onClick={() =>
setModal({ type: "edit", resource: resource as unknown as Resource })
}
className="link-hover-underline mr-3 text-xs font-medium text-brand-600 transition-colors hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
>
Edit
</button>
<button
type="button"
onClick={() =>
setConfirm({
type: "deactivate",
resource: resource as unknown as Resource,
})
}
disabled={isDeactivating}
className="text-xs font-medium text-red-600 transition-colors hover:text-red-800 disabled:opacity-50 dark:text-red-300 dark:hover:text-red-200"
>
{isDeactivating ? "Deactivating…" : "Deactivate"}
</button>
{canManageUsers && (
<button
type="button"
onClick={() =>
setConfirm({
type: "delete",
resource: resource as unknown as Resource,
})
}
disabled={hardDeleteMutation.isPending}
className="ml-3 text-xs font-medium text-red-800 transition-colors hover:text-red-950 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-200"
>
Delete
</button>
)}
</td>
</DraggableTableRow>
);
})}
</tbody>
</table>
{displayedResources.length === 0 && !isLoading && (
<div className="py-14 text-center text-sm text-gray-500">No resources found.</div>
)}
{/* Infinite scroll trigger */}
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
</>
)}
</div>
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{
label: "Export Selected",
variant: "default" as const,
onClick: exportSelectedCsv,
},
...(filterableFields.length > 0
? [
{
label: "Bulk Edit",
variant: "default" as const,
onClick: () => setModal({ type: "bulkEdit" }),
disabled: false,
},
]
: []),
{
label: `Deactivate ${selection.count > 0 ? `(${selection.count})` : ""}`,
variant: "danger" as const,
onClick: () => setConfirm({ type: "batchDeactivate", ids: selection.selectedArray }),
disabled: batchDeactivateMutation.isPending,
},
...(canManageUsers
? [
{
label: `Delete ${selection.count > 0 ? `(${selection.count})` : ""}`,
variant: "danger" as const,
onClick: () => setConfirm({ type: "batchDelete", ids: selection.selectedArray }),
disabled: batchHardDeleteMutation.isPending,
},
]
: []),
]}
/>
{modal.type === "create" && (
<ResourceModal
mode="create"
onClose={closeModal}
onSuccess={(name) => setSuccessToast(`Resource "${name}" created successfully.`)}
/>
)}
{modal.type === "edit" && (
<ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />
)}
{modal.type === "import" && <ImportModal onClose={closeModal} />}
{modal.type === "bulkEdit" && (
<BulkEditModal
selectedIds={selection.selectedArray}
fieldDefs={filterableFields}
onClose={closeModal}
onSuccess={selection.clear}
/>
)}
{confirm.type === "deactivate" && (
<ConfirmDialog
title="Deactivate Resource"
message={`Deactivate "${confirm.resource.displayName}" (${confirm.resource.eid})? This will remove them from the active resource list.`}
confirmLabel="Deactivate"
variant="danger"
onConfirm={handleConfirm}
onCancel={() => setConfirm({ type: "closed" })}
/>
)}
{confirm.type === "batchDeactivate" && (
<ConfirmDialog
title="Deactivate Resources"
message={`Deactivate ${confirm.ids.length} selected resource${confirm.ids.length !== 1 ? "s" : ""}?`}
confirmLabel="Deactivate All"
variant="danger"
onConfirm={handleConfirm}
onCancel={() => setConfirm({ type: "closed" })}
/>
)}
{confirm.type === "delete" && (
<ConfirmDialog
title="Permanently Delete Resource"
message={`Delete "${confirm.resource.displayName}" (${confirm.resource.eid}) permanently? This will also delete all their assignments and vacation records. This action cannot be undone.`}
confirmLabel="Delete Permanently"
variant="danger"
onConfirm={handleConfirm}
onCancel={() => setConfirm({ type: "closed" })}
/>
)}
{confirm.type === "batchDelete" && (
<ConfirmDialog
title="Permanently Delete Resources"
message={`Delete ${confirm.ids.length} selected resource${confirm.ids.length !== 1 ? "s" : ""} permanently? All their assignments and vacation records will also be deleted. This action cannot be undone.`}
confirmLabel="Delete All Permanently"
variant="danger"
onConfirm={handleConfirm}
onCancel={() => setConfirm({ type: "closed" })}
/>
)}
<SuccessToast
show={successToast !== null}
message={successToast ?? ""}
onDone={() => setSuccessToast(null)}
/>
</div>
);
}