chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Resource, SkillEntry } from "@planarchy/shared";
|
||||
import { RESOURCE_COLUMNS } from "@planarchy/shared";
|
||||
import { BlueprintTarget } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { ResourceModal } from "~/components/resources/ResourceModal.js";
|
||||
import { ImportModal } from "~/components/resources/ImportModal.js";
|
||||
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { 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";
|
||||
|
||||
type ModalState =
|
||||
| { type: "closed" }
|
||||
| { type: "create" }
|
||||
| { type: "edit"; resource: Resource }
|
||||
| { type: "import" }
|
||||
| { type: "bulkEdit" };
|
||||
|
||||
type ConfirmState =
|
||||
| { type: "closed" }
|
||||
| { type: "batchDeactivate"; ids: string[] }
|
||||
| { type: "deactivate"; resource: Resource };
|
||||
|
||||
type ActiveFilter = "active" | "inactive" | "all";
|
||||
type ResourceListPage = {
|
||||
resources: Resource[];
|
||||
total: number;
|
||||
nextCursor?: string | null;
|
||||
};
|
||||
|
||||
export function ResourcesClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [chapterFilter, setChapterFilter] = useState("");
|
||||
const [isActiveFilter, setIsActiveFilter] = useState<ActiveFilter>("active");
|
||||
const [modal, setModal] = useState<ModalState>({ type: "closed" });
|
||||
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewScores, canViewCosts } = 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.list.useInfiniteQuery as any)(
|
||||
{
|
||||
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
|
||||
search: search || undefined,
|
||||
chapter: chapterFilter || undefined,
|
||||
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(
|
||||
{},
|
||||
{ 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 ?? [];
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||
const deactivateMutation = trpc.resource.deactivate.useMutation({
|
||||
onSuccess: async () => { await utils.resource.list.invalidate(); },
|
||||
});
|
||||
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, chapterFilter, isActiveFilter]);
|
||||
|
||||
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 });
|
||||
}
|
||||
setConfirm({ type: "closed" });
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setSearch("");
|
||||
setChapterFilter("");
|
||||
setIsActiveFilter("active");
|
||||
clearCustomFilters();
|
||||
}
|
||||
|
||||
const handleFetchNext = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(chapterFilter ? [{ label: `Chapter: ${chapterFilter}`, onRemove: () => setChapterFilter("") }] : []),
|
||||
...(isActiveFilter !== "active" ? [{ label: isActiveFilter === "all" ? "Showing all" : "Inactive only", onRemove: () => setIsActiveFilter("active") }] : []),
|
||||
...customFieldFilters.map((f) => ({
|
||||
label: `${f.key}: ${f.value}`,
|
||||
onRemove: () => setCustomFieldFilter(f.key, "", f.type),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Resources</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-gray-500 text-sm mt-1">{total} resource{total !== 1 ? "s" : ""}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModal({ type: "import" })}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors"
|
||||
>
|
||||
<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="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
<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>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name, EID, email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
|
||||
/>
|
||||
{chapters.length > 0 && (
|
||||
<select
|
||||
value={chapterFilter}
|
||||
onChange={(e) => setChapterFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Chapters</option>
|
||||
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={isActiveFilter}
|
||||
onChange={(e) => setIsActiveFilter(e.target.value as ActiveFilter)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="active">Active only</option>
|
||||
<option value="inactive">Inactive only</option>
|
||||
<option value="all">All resources</option>
|
||||
</select>
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={setVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
/>
|
||||
{isCustomOrder && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOrder}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
|
||||
title="Clear manual row order"
|
||||
>
|
||||
Reset order
|
||||
</button>
|
||||
)}
|
||||
</FilterBar>
|
||||
|
||||
{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="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
{isLoading && resources.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-400 text-sm animate-pulse">Loading resources…</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<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"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
if (col.isCustom) {
|
||||
return (
|
||||
<th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{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 Planarchy 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 allocations on non-DRAFT projects ÷ available working hours this month × 100. Expected (in parentheses) includes DRAFT projects. Target is the management-set goal." 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 text-gray-500 uppercase tracking-wider">Roles <InfoTooltip content="Primary role (★) and additional roles assigned to this resource. Used for open demand and staffing suggestions." /></th>;
|
||||
case "isActive":
|
||||
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">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 text-gray-500 uppercase tracking-wider">{col.label}</th>;
|
||||
}
|
||||
})}
|
||||
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{displayedResources.map((resource) => {
|
||||
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={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" checked={isSelected} onChange={() => selection.toggle(resource.id)} className="rounded border-gray-300" />
|
||||
</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">{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">{resource.eid}</td>;
|
||||
case "displayName":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<Link href={`/resources/${resource.id}`} className="text-sm font-medium text-gray-900 hover:text-brand-600 hover:underline transition-colors">{resource.displayName}</Link>
|
||||
<div className="text-xs text-gray-500">{resource.email}</div>
|
||||
</td>
|
||||
);
|
||||
case "chapter":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{resource.chapter ?? "—"}</td>;
|
||||
case "lcr":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900">{(resource.lcrCents / 100).toFixed(0)} {resource.currency}</td>;
|
||||
case "chargeability": {
|
||||
if (!canViewCosts) return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">{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" : actual >= target - 20 ? "text-amber-600" : "text-red-600";
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm">
|
||||
<div>
|
||||
<span className={`font-medium ${color}`}>{actual != null ? `${actual}%` : "—"}</span>
|
||||
{expected != null && expected !== actual && <span className="text-xs text-gray-400 ml-1">({expected}% exp.)</span>}
|
||||
<div className="text-xs text-gray-400">Target: {target}%</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 px-2 py-0.5 rounded-full text-xs font-semibold ${score >= 70 ? "bg-green-100 text-green-700" : score >= 40 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>{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 px-2 py-0.5 text-xs rounded-full 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 "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 px-2 py-0.5 text-xs bg-brand-50 text-brand-700 rounded-full">{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">—</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="text-xs font-medium text-brand-600 hover:text-brand-800 transition-colors mr-3">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 hover:text-red-800 transition-colors disabled:opacity-50">{isDeactivating ? "Deactivating…" : "Deactivate"}</button>
|
||||
</td>
|
||||
</DraggableTableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{displayedResources.length === 0 && !isLoading && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No resources found.</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
...(filterableFields.length > 0 ? [{
|
||||
label: "Edit Custom Fields",
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{modal.type === "create" && <ResourceModal mode="create" onClose={closeModal} />}
|
||||
{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" })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user