@@ -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 >
) ;
}