"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([]); const [includeWithoutCountry, setIncludeWithoutCountry] = useState(true); const [hiddenResourceTypes, setHiddenResourceTypes] = useState([ ...DEFAULT_HIDDEN_RESOURCE_TYPES, ]); const [includeWithoutResourceType, setIncludeWithoutResourceType] = useState(true); const [modal, setModal] = useState({ type: "closed" }); const [confirm, setConfirm] = useState({ type: "closed" }); const [successToast, setSuccessToast] = useState(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; 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(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(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 (

Resources

{!isLoading && (

{total} resource{total !== 1 ? "s" : ""}

)}
{/* Filters + Column toggle */}
setSearchInput(e.target.value)} className="app-input max-w-xs" />
{chapters.length > 0 && (

Chapter

Checked chapters stay visible.

{chapters.map((chapter) => ( ))}
)}

Resource Status

Select one visibility mode.

{[ { value: "active", label: "Active only" }, { value: "inactive", label: "Inactive only" }, { value: "all", label: "All resources" }, ].map((option, index) => ( ))}

Rolled Off

Select one filter value.

{[ { value: "all", label: "All" }, { value: "yes", label: "Yes" }, { value: "no", label: "No" }, ].map((option, index) => ( ))}

Departed

Select one filter value.

{[ { value: "all", label: "All" }, { value: "yes", label: "Yes" }, { value: "no", label: "No" }, ].map((option, index) => ( ))}
{canViewCosts && ( )} {isCustomOrder && ( )}

Countries

Checked countries stay visible.

{countries.map((country) => ( ))}

Resource Types

Checked types stay visible.

{allResourceTypes.map((resourceType) => ( ))}
{filterableFields.length > 0 && (
)} {chips.length > 0 && (
)} {/* Table */}
{isLoading && resources.length === 0 ? (
Loading resources…
) : ( <> {/* Drag handle column */} {visibleColumns.map((col) => { if (col.isCustom) { return ( ); } switch (col.key) { case "eid": return ( ); case "displayName": return ( ); case "chapter": return ( ); case "lcr": return ( ); case "chargeability": return ( ); case "valueScore": return canViewScores ? ( ) : null; case "roles": return ( ); case "rolledOff": return ( ); case "departed": return ( ); case "isActive": return ( ); default: return ( ); } })} {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 }) .dynamicFields ?? {}; return ( 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` }} > {visibleColumns.map((col) => { if (col.isCustom) { const fieldKey = col.key.replace(/^custom_/, ""); const val = dynFields[fieldKey]; return ( ); } switch (col.key) { case "eid": return ( ); 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 ( ); } case "chapter": return ( ); case "lcr": return ( ); case "chargeability": { if (!canViewCosts) return ( ); 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 ( ); } case "valueScore": { if (!canViewScores) return null; const score = (resource as unknown as { valueScore?: number | null }) .valueScore; return ( ); } case "roles": { const rr = ( resource as unknown as { resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null }; }[]; } ).resourceRoles ?? []; return ( ); } case "rolledOff": return ( ); case "departed": return ( ); case "isActive": return ( ); default: return ( ); } })} ); })}
{ if (el) el.indeterminate = selection.isIndeterminate(resourceIds); }} onChange={() => selection.toggleAll(resourceIds)} className="rounded border-gray-300 dark:border-gray-600" /> {col.label} Roles{" "} Rolled Off Departed Skills{" "} {col.label} Actions
selection.toggle(resource.id)} className="rounded border-gray-300 dark:border-gray-600" /> {val != null ? String(val) : "—"} {resource.eid} {initials} {resource.displayName} {resource.email} {resource.chapter ?? "—"} {formatMoney(resource.lcrCents, resource.currency)} {resource.chargeabilityTarget}%
{actual != null ? `${actual}%` : "—"} {expected != null && expected !== actual && ( ({expected}% exp.) )} {actual !== target && (
Target: {target}%
)} {actual != null && (
{isOverflow && ( + )}
)}
{score != null ? ( = 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} ) : ( )}
{rr.map((r) => ( {r.isPrimary && } {r.role.name} ))} {rr.length === 0 && ( )}
{skills.slice(0, 3).map((s) => ( {s.skill} ))} {skills.length > 3 && ( +{skills.length - 3} )}
{canManageUsers && ( )}
{displayedResources.length === 0 && !isLoading && (
No resources found.
)} {/* Infinite scroll trigger */} )}
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" && ( setSuccessToast(`Resource "${name}" created successfully.`)} /> )} {modal.type === "edit" && ( )} {modal.type === "import" && } {modal.type === "bulkEdit" && ( )} {confirm.type === "deactivate" && ( setConfirm({ type: "closed" })} /> )} {confirm.type === "batchDeactivate" && ( setConfirm({ type: "closed" })} /> )} {confirm.type === "delete" && ( setConfirm({ type: "closed" })} /> )} {confirm.type === "batchDelete" && ( setConfirm({ type: "closed" })} /> )} setSuccessToast(null)} />
); }