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

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

231 lines
9.7 KiB
TypeScript

import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
import { formatDate } from "~/lib/format.js";
import { AllocationRow } from "./AllocationRow.js";
type ProjectSubGroup = {
projectId: string;
projectName: string;
projectCode: string;
allocations: AllocationWithDetails[];
typicalHoursPerDay: number;
earliestStart: Date;
latestEnd: Date;
};
export type AllocGroup = {
resourceId: string;
resourceName: string;
eid: string;
chapter: string | null;
allocations: AllocationWithDetails[];
projectSubGroups: ProjectSubGroup[];
};
/** Fragment wrapper for grouped rows — avoids unnecessary DOM nodes */
function GroupRows({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
type AllocationGroupedBodyProps = {
groups: AllocGroup[];
collapsedGroups: CollapsedAllocationGroups;
expandedSubGroups: Set<string>;
visibleColumns: ColumnDef[];
selectedIds: Set<string>;
isAllSelected: (ids: string[]) => boolean;
isIndeterminate?: (ids: string[]) => boolean;
onToggleSelection: (id: string) => void;
onToggleAllSelection: (ids: string[]) => void;
onToggleGroup: (resourceId: string) => void;
onToggleSubGroup: (resourceId: string, projectId: string) => void;
onEdit: (alloc: AllocationWithDetails) => void;
onRequestDelete: (alloc: AllocationWithDetails) => void;
deleteDisabled: boolean;
formatPeriod: (alloc: AllocationWithDetails) => string;
};
export function AllocationGroupedBody({
groups,
collapsedGroups,
expandedSubGroups,
visibleColumns,
selectedIds,
isAllSelected,
onToggleSelection,
onToggleAllSelection,
onToggleGroup,
onToggleSubGroup,
onEdit,
onRequestDelete,
deleteDisabled,
formatPeriod,
}: AllocationGroupedBodyProps) {
return (
<>
{groups.map((group) => {
const isCollapsed = collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
const groupAllocIds = group.allocations.map((a) => a.id);
const allGroupSelected = isAllSelected(groupAllocIds);
const groupIndeterminate =
!allGroupSelected && groupAllocIds.some((id) => selectedIds.has(id));
return (
<GroupRows key={group.resourceId}>
{/* Group header */}
<tr
data-testid="allocation-group-header"
className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors"
onClick={() => onToggleGroup(group.resourceId)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleGroup(group.resourceId);
}
}}
tabIndex={0}
role="button"
aria-expanded={!isCollapsed}
>
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={allGroupSelected}
ref={(el) => {
if (el) el.indeterminate = groupIndeterminate;
}}
onChange={() => onToggleAllSelection(groupAllocIds)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
<td colSpan={visibleColumns.length + 1} className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-gray-400 dark:text-gray-500 text-xs">
{isCollapsed ? "▸" : "▾"}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{group.resourceName}
</span>
{group.eid && (
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
{group.eid}
</span>
)}
{group.chapter && (
<span className="text-xs text-gray-400 dark:text-gray-500">
· {group.chapter}
</span>
)}
<span className="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{group.allocations.length}
</span>
</div>
</td>
</tr>
{/* Project sub-groups within person */}
{!isCollapsed &&
group.projectSubGroups.map((subGroup) => {
const subKey = `${group.resourceId}::${subGroup.projectId}`;
const isSubExpanded = expandedSubGroups.has(subKey);
// Single allocation for this project — render directly, no sub-group header
if (subGroup.allocations.length === 1) {
const alloc = subGroup.allocations[0]!;
return (
<GroupRows key={subKey}>
<AllocationRow
alloc={alloc}
visibleColumns={visibleColumns}
isGrouped
rowIndex={0}
isSelected={selectedIds.has(alloc.id)}
onToggleSelection={() => onToggleSelection(alloc.id)}
onEdit={() => onEdit(alloc)}
onRequestDelete={() => onRequestDelete(alloc)}
deleteDisabled={deleteDisabled}
formatPeriod={formatPeriod}
/>
</GroupRows>
);
}
// Multiple allocations — show collapsible project sub-group
const subAllocIds = subGroup.allocations.map((a) => a.id);
const allSubSelected = isAllSelected(subAllocIds);
const subIndeterminate =
!allSubSelected && subAllocIds.some((id) => selectedIds.has(id));
return (
<GroupRows key={subKey}>
<tr
data-testid="allocation-subgroup-header"
className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors"
onClick={() => onToggleSubGroup(group.resourceId, subGroup.projectId)}
tabIndex={0}
role="button"
aria-expanded={isSubExpanded}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleSubGroup(group.resourceId, subGroup.projectId);
}
}}
>
<td className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={allSubSelected}
ref={(el) => {
if (el) el.indeterminate = subIndeterminate;
}}
onChange={() => onToggleAllSelection(subAllocIds)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
<td colSpan={visibleColumns.length + 1} className="px-4 py-2">
<div className="flex items-center gap-2 pl-4">
<span className="text-gray-400 dark:text-gray-500 text-xs">
{isSubExpanded ? "▾" : "▸"}
</span>
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">
{subGroup.projectCode}
</span>
<span className="text-sm text-gray-700 dark:text-gray-300">
{subGroup.projectName}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
{formatDate(subGroup.earliestStart)} {formatDate(subGroup.latestEnd)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{subGroup.typicalHoursPerDay}h/day
</span>
<span className="inline-flex items-center rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">
{subGroup.allocations.length}
</span>
</div>
</td>
</tr>
{isSubExpanded &&
subGroup.allocations.map((alloc, idx) => (
<AllocationRow
key={alloc.id}
alloc={alloc}
visibleColumns={visibleColumns}
isGrouped
rowIndex={idx}
isSelected={selectedIds.has(alloc.id)}
onToggleSelection={() => onToggleSelection(alloc.id)}
onEdit={() => onEdit(alloc)}
onRequestDelete={() => onRequestDelete(alloc)}
deleteDisabled={deleteDisabled}
formatPeriod={formatPeriod}
/>
))}
</GroupRows>
);
})}
</GroupRows>
);
})}
</>
);
}