4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
manifest, mobile header, MFA backup-codes header, tooltips, signin
page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
tooling/deploy/.env.production.example brand sweep
Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml
Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
231 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|