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) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
1516 lines
66 KiB
TypeScript
1516 lines
66 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||
import 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 0–100. 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>
|
||
);
|
||
}
|