From 17f2de5f4894c9ba57271a4527bc2ee20bc2231e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 11 Apr 2026 08:49:50 +0200 Subject: [PATCH] refactor(web): decompose AllocationsClient and UsersClient into focused subcomponents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AllocationsClient (1364→962 lines): extracted AllocationRow, AllocationGroupedBody, OpenDemandsPanel, and AllocationBatchDialogs. UsersClient (1338→895 lines): extracted UserEditModal and UserCreateModal. Co-Authored-By: Claude Opus 4.6 --- .../src/components/admin/UserCreateModal.tsx | 142 +++++ .../src/components/admin/UserEditModal.tsx | 367 ++++++++++++ apps/web/src/components/admin/UsersClient.tsx | 513 ++-------------- .../allocations/AllocationBatchDialogs.tsx | 175 ++++++ .../allocations/AllocationGroupedBody.tsx | 230 ++++++++ .../components/allocations/AllocationRow.tsx | 143 +++++ .../allocations/AllocationsClient.tsx | 546 +++--------------- .../allocations/OpenDemandsPanel.tsx | 94 +++ 8 files changed, 1257 insertions(+), 953 deletions(-) create mode 100644 apps/web/src/components/admin/UserCreateModal.tsx create mode 100644 apps/web/src/components/admin/UserEditModal.tsx create mode 100644 apps/web/src/components/allocations/AllocationBatchDialogs.tsx create mode 100644 apps/web/src/components/allocations/AllocationGroupedBody.tsx create mode 100644 apps/web/src/components/allocations/AllocationRow.tsx create mode 100644 apps/web/src/components/allocations/OpenDemandsPanel.tsx diff --git a/apps/web/src/components/admin/UserCreateModal.tsx b/apps/web/src/components/admin/UserCreateModal.tsx new file mode 100644 index 0000000..248d5c5 --- /dev/null +++ b/apps/web/src/components/admin/UserCreateModal.tsx @@ -0,0 +1,142 @@ +import { SystemRole } from "@capakraken/shared"; +import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; + +const SYSTEM_ROLE_LABELS: Record = { + [SystemRole.ADMIN]: "Admin", + [SystemRole.MANAGER]: "Manager", + [SystemRole.CONTROLLER]: "Controller", + [SystemRole.USER]: "User", + [SystemRole.VIEWER]: "Viewer", +}; + +export type CreateState = { + name: string; + email: string; + password: string; + systemRole: SystemRole; +}; + +type UserCreateModalProps = { + state: CreateState; + actionError: string | null; + isPending: boolean; + createPending: boolean; + onChange: (state: CreateState) => void; + onSubmit: () => void; + onClose: () => void; +}; + +export function UserCreateModal({ + state, + actionError, + isPending, + createPending, + onChange, + onSubmit, + onClose, +}: UserCreateModalProps) { + return ( +
+
+
+

Create User

+ +
+ +
+ {actionError && ( +
+ {actionError} +
+ )} + +
+ + onChange({ ...state, name: e.target.value })} + placeholder="Max Mustermann" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+ +
+ + onChange({ ...state, email: e.target.value })} + placeholder="user@example.com" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+ +
+ + onChange({ ...state, password: e.target.value })} + placeholder="Min. 8 characters" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + autoComplete="new-password" + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/admin/UserEditModal.tsx b/apps/web/src/components/admin/UserEditModal.tsx new file mode 100644 index 0000000..c1aff76 --- /dev/null +++ b/apps/web/src/components/admin/UserEditModal.tsx @@ -0,0 +1,367 @@ +import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared"; +import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; + +const ALL_PERMISSION_KEYS = Object.values(PermissionKey); + +const PERMISSION_LABELS: Record = { + viewPlanning: "View Planning", + viewCosts: "View Costs", + useAssistantAdvancedTools: "Assistant Advanced Tools", + exportData: "Export Data", + importData: "Import Data", + approveVacations: "Approve Vacations", + manageBlueprints: "Manage Blueprints", + viewAllResources: "View All Resources", + manageResources: "Manage Resources", + manageProjects: "Manage Projects", + manageAllocations: "Manage Allocations", + manageRoles: "Manage Roles", + manageUsers: "Manage Users", + viewScores: "View Scores", +}; + +const SYSTEM_ROLE_LABELS: Record = { + [SystemRole.ADMIN]: "Admin", + [SystemRole.MANAGER]: "Manager", + [SystemRole.CONTROLLER]: "Controller", + [SystemRole.USER]: "User", + [SystemRole.VIEWER]: "Viewer", +}; + +export type EditState = { + userId: string; + systemRole: SystemRole; + granted: Set; + denied: Set; + chapterIds: string; +}; + +type UserEditModalProps = { + editState: EditState; + selectedUserName: string; + editingName: { userId: string; name: string } | null; + roleDefaultsMap: Record; + isPending: boolean; + updateRolePending: boolean; + setPermissionsPending: boolean; + resetPermissionsPending: boolean; + updateNamePending: boolean; + onEditStateChange: (state: EditState) => void; + onSaveRole: () => void; + onSavePermissions: () => void; + onReset: () => void; + onClose: () => void; + onEditingNameChange: (state: { userId: string; name: string } | null) => void; + onSaveName: (userId: string, name: string) => void; + currentName: string; +}; + +export function UserEditModal({ + editState, + selectedUserName, + editingName, + roleDefaultsMap, + isPending, + updateRolePending, + setPermissionsPending, + resetPermissionsPending, + updateNamePending, + onEditStateChange, + onSaveRole, + onSavePermissions, + onReset, + onClose, + onEditingNameChange, + onSaveName, + currentName, +}: UserEditModalProps) { + function cyclePermission(key: string) { + const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []); + const isRoleDefault = roleDefaults.has(key as PermissionKey); + const isGranted = editState.granted.has(key); + const isDenied = editState.denied.has(key); + + const nextGranted = new Set(editState.granted); + const nextDenied = new Set(editState.denied); + + if (isRoleDefault) { + if (isDenied) { + nextDenied.delete(key); + } else { + nextDenied.add(key); + nextGranted.delete(key); + } + } else { + if (isGranted) { + nextGranted.delete(key); + } else { + nextGranted.add(key); + nextDenied.delete(key); + } + } + onEditStateChange({ ...editState, granted: nextGranted, denied: nextDenied }); + } + + return ( +
+
+ {/* Modal Header */} +
+
+

Edit User

+

{selectedUserName}

+
+ +
+ + {/* Modal Body */} +
+ {/* User Name */} +
+

+ Display Name +

+ {editingName?.userId === editState.userId ? ( +
+ onEditingNameChange({ ...editingName, name: e.target.value })} + className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && editingName.name.trim()) { + onSaveName(editingName.userId, editingName.name.trim()); + } + if (e.key === "Escape") onEditingNameChange(null); + }} + /> + + +
+ ) : ( +
+ + {currentName || "—"} + + +
+ )} +
+ + {/* System Role */} +
+

+ System Role{" "} + +

+
+ + +
+
+ + {/* Permissions */} +
+

+ Permissions{" "} + +

+
+ + {" "} + Role default + + + {" "} + Extra grant + + + + + × + + {" "} + Denied + +
+
+ {ALL_PERMISSION_KEYS.map((key) => { + const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []); + const isRoleDefault = roleDefaults.has(key as PermissionKey); + const isGranted = editState.granted.has(key); + const isDenied = editState.denied.has(key); + + let state: "default" | "granted" | "denied" | "off"; + if (isDenied) state = "denied"; + else if (isGranted) state = "granted"; + else if (isRoleDefault) state = "default"; + else state = "off"; + + const stateStyles = { + default: + "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800", + granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800", + denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800", + off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700", + }; + + const checkStyles = { + default: "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40", + granted: "text-blue-600 border-blue-300 bg-blue-100 dark:bg-blue-900/40", + denied: "text-red-600 border-red-300 bg-red-100 dark:bg-red-900/40", + off: "text-gray-400 border-gray-300 dark:border-gray-600", + }; + + return ( + + ); + })} +
+ + {/* Chapter Scope */} +
+ + onEditStateChange({ ...editState, chapterIds: e.target.value })} + placeholder="e.g. chapter-1, chapter-2" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+
+
+ + {/* Modal Footer */} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx index 9885f37..bd27db2 100644 --- a/apps/web/src/components/admin/UsersClient.tsx +++ b/apps/web/src/components/admin/UsersClient.tsx @@ -1,9 +1,9 @@ "use client"; import { useState, useMemo } from "react"; +import type { PermissionKey } from "@capakraken/shared"; import { SystemRole, - PermissionKey, ROLE_DEFAULT_PERMISSIONS, MILLISECONDS_PER_DAY, type PermissionOverrides, @@ -13,29 +13,11 @@ import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { InviteUserModal } from "./InviteUserModal.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { FilterChips } from "~/components/ui/FilterChips.js"; -import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; - -const ALL_PERMISSION_KEYS = Object.values(PermissionKey); - -const PERMISSION_LABELS: Record = { - viewPlanning: "View Planning", - viewCosts: "View Costs", - useAssistantAdvancedTools: "Assistant Advanced Tools", - exportData: "Export Data", - importData: "Import Data", - approveVacations: "Approve Vacations", - manageBlueprints: "Manage Blueprints", - viewAllResources: "View All Resources", - manageResources: "Manage Resources", - manageProjects: "Manage Projects", - manageAllocations: "Manage Allocations", - manageRoles: "Manage Roles", - manageUsers: "Manage Users", - viewScores: "View Scores", -}; +import { UserEditModal, type EditState } from "./UserEditModal.js"; +import { UserCreateModal, type CreateState } from "./UserCreateModal.js"; const SYSTEM_ROLE_LABELS: Record = { [SystemRole.ADMIN]: "Admin", @@ -75,21 +57,6 @@ type UserRow = { isActive: boolean; }; -type EditState = { - userId: string; - systemRole: SystemRole; - granted: Set; - denied: Set; - chapterIds: string; -}; - -type CreateState = { - name: string; - email: string; - password: string; - systemRole: SystemRole; -}; - const EMPTY_CREATE: CreateState = { name: "", email: "", @@ -127,7 +94,6 @@ export function UsersClient() { staleTime: 60_000, }); - // Build dynamic role defaults map from DB config (fallback to hardcoded) const roleDefaultsMap = useMemo(() => { if (!roleConfigs) return ROLE_DEFAULT_PERMISSIONS; const map: Record = {}; @@ -301,32 +267,6 @@ export function UsersClient() { setActionError(null); } - function toggleGranted(key: string) { - if (!editState) return; - const next = new Set(editState.granted); - const nextDenied = new Set(editState.denied); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - nextDenied.delete(key); - } - setEditState({ ...editState, granted: next, denied: nextDenied }); - } - - function toggleDenied(key: string) { - if (!editState) return; - const next = new Set(editState.denied); - const nextGranted = new Set(editState.granted); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - nextGranted.delete(key); - } - setEditState({ ...editState, denied: next, granted: nextGranted }); - } - async function handleSaveRole() { if (!editState) return; setActionError(null); @@ -365,7 +305,6 @@ export function UsersClient() { const allUsers = (users ?? []) as unknown as UserRow[]; - // Client-side filtering const filteredUsers = allUsers.filter((u) => { if (search) { const q = search.toLowerCase(); @@ -913,425 +852,41 @@ export function UsersClient() { {/* Create User Modal */} {createState && ( -
-
-
-

- Create User -

- -
- -
- {actionError && ( -
- {actionError} -
- )} - -
- - setCreateState({ ...createState, name: e.target.value })} - placeholder="Max Mustermann" - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" - /> -
- -
- - setCreateState({ ...createState, email: e.target.value })} - placeholder="user@example.com" - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" - /> -
- -
- - setCreateState({ ...createState, password: e.target.value })} - placeholder="Min. 8 characters" - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" - autoComplete="new-password" - /> -
- -
- - -
-
- -
- - -
-
-
+ void handleCreateUser()} + onClose={() => { + setCreateState(null); + setActionError(null); + }} + /> )} {/* Edit Modal */} {editState && selectedUser && ( -
-
- {/* Modal Header */} -
-
-

- Edit User -

-

- {selectedUser.name ?? selectedUser.email} -

-
- -
- - {/* Modal Body */} -
- {/* User Name */} -
-

- Display Name -

- {editingName?.userId === editState.userId ? ( -
- setEditingName({ ...editingName, name: e.target.value })} - className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter" && editingName.name.trim()) { - updateNameMutation.mutate({ - id: editingName.userId, - name: editingName.name.trim(), - }); - } - if (e.key === "Escape") setEditingName(null); - }} - /> - - -
- ) : ( -
- - {(users as any)?.find((u: any) => u.id === editState.userId)?.name ?? "—"} - - -
- )} -
- - {/* System Role */} -
-

- System Role{" "} - -

-
- - -
-
- - {/* Permissions */} -
-

- Permissions{" "} - -

-
- - {" "} - Role default - - - {" "} - Extra grant - - - - - × - - {" "} - Denied - -
-
- {ALL_PERMISSION_KEYS.map((key) => { - const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []); - const isRoleDefault = roleDefaults.has(key as PermissionKey); - const isGranted = editState.granted.has(key); - const isDenied = editState.denied.has(key); - - // Determine display state - let state: "default" | "granted" | "denied" | "off"; - if (isDenied) state = "denied"; - else if (isGranted) state = "granted"; - else if (isRoleDefault) state = "default"; - else state = "off"; - - function cycleState() { - if (!editState) return; - const nextGranted = new Set(editState.granted); - const nextDenied = new Set(editState.denied); - - if (isRoleDefault) { - // Role default: off → denied → off - if (isDenied) { - nextDenied.delete(key); - } else { - nextDenied.add(key); - nextGranted.delete(key); - } - } else { - // Non-default: off → granted → off - if (isGranted) { - nextGranted.delete(key); - } else { - nextGranted.add(key); - nextDenied.delete(key); - } - } - setEditState({ ...editState, granted: nextGranted, denied: nextDenied }); - } - - const stateStyles = { - default: - "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800", - granted: - "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800", - denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800", - off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700", - }; - - const checkStyles = { - default: "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40", - granted: "text-blue-600 border-blue-300 bg-blue-100 dark:bg-blue-900/40", - denied: "text-red-600 border-red-300 bg-red-100 dark:bg-red-900/40", - off: "text-gray-400 border-gray-300 dark:border-gray-600", - }; - - return ( - - ); - })} -
- - {/* Chapter Scope */} -
- - setEditState({ ...editState, chapterIds: e.target.value })} - placeholder="e.g. chapter-1, chapter-2" - className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" - /> -
-
-
- - {/* Modal Footer */} -
- -
- - -
-
-
-
+ void handleSaveRole()} + onSavePermissions={() => void handleSavePermissions()} + onReset={() => void handleReset()} + onClose={closeEdit} + onEditingNameChange={setEditingName} + onSaveName={(userId, name) => updateNameMutation.mutate({ id: userId, name })} + currentName={(allUsers.find((u) => u.id === editState.userId)?.name ?? "") as string} + /> )} ); diff --git a/apps/web/src/components/allocations/AllocationBatchDialogs.tsx b/apps/web/src/components/allocations/AllocationBatchDialogs.tsx new file mode 100644 index 0000000..bcee2b3 --- /dev/null +++ b/apps/web/src/components/allocations/AllocationBatchDialogs.tsx @@ -0,0 +1,175 @@ +import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared"; +import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; +import { BatchActionBar } from "~/components/ui/BatchActionBar.js"; +import { BatchDateShiftModal } from "./BatchDateShiftModal.js"; + +const ALL_ALLOC_STATUSES = [ + { value: "PROPOSED", label: "Proposed" }, + { value: "CONFIRMED", label: "Confirmed" }, + { value: "ACTIVE", label: "Active" }, + { value: "COMPLETED", label: "Completed" }, + { value: "CANCELLED", label: "Cancelled" }, +] as const; + +type ConfirmDeleteState = { + single?: AllocationWithDetails; + ids?: string[]; +} | null; + +type ConfirmBatchStatusState = { + ids: string[]; + status: string; +} | null; + +type AllocationBatchDialogsProps = { + selectionCount: number; + selectedMutationIds: string[]; + onClearSelection: () => void; + + // Batch status picker + batchStatusPickerOpen: boolean; + onOpenBatchStatusPicker: () => void; + onCloseBatchStatusPicker: () => void; + batchStatusPending: boolean; + onBatchStatusConfirm: (ids: string[], status: AllocationStatus) => void; + + // Delete + confirmDelete: ConfirmDeleteState; + onSetConfirmDelete: (state: ConfirmDeleteState) => void; + onSingleDelete: (alloc: AllocationWithDetails) => void; + onBatchDelete: (ids: string[]) => void; + batchDeletePending: boolean; + + // Date shift + showDateShiftModal: boolean; + onOpenDateShiftModal: () => void; + onCloseDateShiftModal: () => void; + onDateShiftConfirm: (daysDelta: number) => void; + dateShiftPending: boolean; +}; + +export function AllocationBatchDialogs({ + selectionCount, + selectedMutationIds, + onClearSelection, + batchStatusPickerOpen, + onOpenBatchStatusPicker, + onCloseBatchStatusPicker, + batchStatusPending, + onBatchStatusConfirm, + confirmDelete, + onSetConfirmDelete, + onSingleDelete, + onBatchDelete, + batchDeletePending, + showDateShiftModal, + onOpenDateShiftModal, + onCloseDateShiftModal, + onDateShiftConfirm, + dateShiftPending, +}: AllocationBatchDialogsProps) { + return ( + <> + {/* Batch Status Picker */} + {batchStatusPickerOpen && ( +
+
e.stopPropagation()} + > +

+ Set status for {selectionCount} allocations +

+
+ {ALL_ALLOC_STATUSES.map((s) => ( + + ))} +
+
+
+ )} + + {/* Confirm single delete */} + {confirmDelete?.single && ( + { + onSingleDelete(confirmDelete.single!); + onSetConfirmDelete(null); + }} + onCancel={() => onSetConfirmDelete(null)} + /> + )} + + {/* Confirm batch delete */} + {confirmDelete?.ids && ( + { + onBatchDelete(confirmDelete.ids!); + onSetConfirmDelete(null); + }} + onCancel={() => onSetConfirmDelete(null)} + /> + )} + + {/* Batch Action Bar */} + onSetConfirmDelete({ ids: selectedMutationIds }), + disabled: batchDeletePending, + }, + ]} + /> + + {/* Batch date shift modal */} + {showDateShiftModal && ( + + )} + + ); +} diff --git a/apps/web/src/components/allocations/AllocationGroupedBody.tsx b/apps/web/src/components/allocations/AllocationGroupedBody.tsx new file mode 100644 index 0000000..154e5f8 --- /dev/null +++ b/apps/web/src/components/allocations/AllocationGroupedBody.tsx @@ -0,0 +1,230 @@ +import type { AllocationWithDetails, ColumnDef } from "@capakraken/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; + visibleColumns: ColumnDef[]; + selectedIds: Set; + 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 ( + + {/* Group header */} + onToggleGroup(group.resourceId)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggleGroup(group.resourceId); + } + }} + tabIndex={0} + role="button" + aria-expanded={!isCollapsed} + > + e.stopPropagation()}> + { + if (el) el.indeterminate = groupIndeterminate; + }} + onChange={() => onToggleAllSelection(groupAllocIds)} + className="rounded border-gray-300 dark:border-gray-600" + /> + + +
+ + {isCollapsed ? "▸" : "▾"} + + + {group.resourceName} + + {group.eid && ( + + {group.eid} + + )} + {group.chapter && ( + + · {group.chapter} + + )} + + {group.allocations.length} + +
+ + + {/* 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 ( + + onToggleSelection(alloc.id)} + onEdit={() => onEdit(alloc)} + onRequestDelete={() => onRequestDelete(alloc)} + deleteDisabled={deleteDisabled} + formatPeriod={formatPeriod} + /> + + ); + } + + // 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 ( + + 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); + } + }} + > + e.stopPropagation()}> + { + if (el) el.indeterminate = subIndeterminate; + }} + onChange={() => onToggleAllSelection(subAllocIds)} + className="rounded border-gray-300 dark:border-gray-600" + /> + + +
+ + {isSubExpanded ? "▾" : "▸"} + + + {subGroup.projectCode} + + + {subGroup.projectName} + + + {formatDate(subGroup.earliestStart)} → {formatDate(subGroup.latestEnd)} + + + {subGroup.typicalHoursPerDay}h/day + + + {subGroup.allocations.length} + +
+ + + {isSubExpanded && + subGroup.allocations.map((alloc, idx) => ( + onToggleSelection(alloc.id)} + onEdit={() => onEdit(alloc)} + onRequestDelete={() => onRequestDelete(alloc)} + deleteDisabled={deleteDisabled} + formatPeriod={formatPeriod} + /> + ))} +
+ ); + })} +
+ ); + })} + + ); +} diff --git a/apps/web/src/components/allocations/AllocationRow.tsx b/apps/web/src/components/allocations/AllocationRow.tsx new file mode 100644 index 0000000..3ec992b --- /dev/null +++ b/apps/web/src/components/allocations/AllocationRow.tsx @@ -0,0 +1,143 @@ +import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared"; +import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; + +const STATUS_LEFT_BORDER: Record = { + ACTIVE: "border-l-green-500", + PROPOSED: "border-l-amber-500", + CONFIRMED: "border-l-blue-500", + COMPLETED: "border-l-gray-400", + CANCELLED: "border-l-red-500", +}; + +type AllocationRowProps = { + alloc: AllocationWithDetails; + visibleColumns: ColumnDef[]; + isGrouped?: boolean; + rowIndex?: number; + isSelected: boolean; + onToggleSelection: () => void; + onEdit: () => void; + onRequestDelete: () => void; + deleteDisabled: boolean; + formatPeriod: (alloc: AllocationWithDetails) => string; +}; + +export function AllocationRow({ + alloc, + visibleColumns, + isGrouped = false, + rowIndex = 0, + isSelected, + onToggleSelection, + onEdit, + onRequestDelete, + deleteDisabled, + formatPeriod, +}: AllocationRowProps) { + const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300"; + return ( + + + + + {visibleColumns.map((col) => { + switch (col.key) { + case "resource": + return ( + + {isGrouped ? ( + + ) : ( + (alloc.resource?.displayName ?? "—") + )} + + ); + case "project": + return ( + + {alloc.project ? ( + <> + {alloc.project.shortCode}{" "} + {alloc.project.name} + + ) : ( + "—" + )} + + ); + case "role": + return ( + + {alloc.role} + + ); + case "dates": + return ( + + {formatPeriod(alloc)} + + ); + case "hoursPerDay": + return ( + + {alloc.hoursPerDay}h + + ); + case "cost": + return ( + + {(alloc.dailyCostCents / 100).toFixed(0)} € + + ); + case "status": + return ( + + + {alloc.status} + + + ); + default: + return ( + + — + + ); + } + })} + +
+ + +
+ + + ); +} diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index feaa8bf..e03b543 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -16,8 +16,6 @@ import type { } from "@capakraken/shared"; import { ALLOCATION_COLUMNS } from "@capakraken/shared"; 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 { ResourceCombobox } from "~/components/ui/ResourceCombobox.js"; @@ -29,10 +27,8 @@ import { usePermissions } from "~/hooks/usePermissions.js"; import { useColumnConfig } from "~/hooks/useColumnConfig.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; -import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { EmptyState } from "~/components/ui/EmptyState.js"; -import { BatchDateShiftModal } from "./BatchDateShiftModal.js"; import { downloadWorkbookSheets } from "~/lib/workbook-export.js"; import { collapseAllAllocationGroups, @@ -45,20 +41,11 @@ import { getAllocationEmptyState, shouldAutoRelaxAllocationFilters, } from "./allocationVisibilityState.js"; - -/** Left-border color by allocation status for instant visual scanning */ -const STATUS_LEFT_BORDER: Record = { - ACTIVE: "border-l-green-500", - PROPOSED: "border-l-amber-500", - CONFIRMED: "border-l-blue-500", - COMPLETED: "border-l-gray-400", - CANCELLED: "border-l-red-500", -}; - -/** Fragment wrapper for grouped rows — avoids unnecessary DOM nodes */ -function GroupRows({ children }: { children: React.ReactNode }) { - return <>{children}; -} +import { AllocationRow } from "./AllocationRow.js"; +import { AllocationGroupedBody, type AllocGroup } from "./AllocationGroupedBody.js"; +import { OpenDemandsPanel } from "./OpenDemandsPanel.js"; +import { AllocationBatchDialogs } from "./AllocationBatchDialogs.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; const ALL_ALLOC_STATUSES = [ { value: "PROPOSED", label: "Proposed" }, @@ -171,7 +158,6 @@ export function AllocationsClient() { const [showDateShiftModal, setShowDateShiftModal] = useState(false); const selection = useSelection(); - const utils = trpc.useUtils(); const invalidatePlanningViews = useInvalidatePlanningViews(); const { canViewCosts } = usePermissions(); @@ -348,7 +334,6 @@ export function AllocationsClient() { const [collapsedGroups, setCollapsedGroups] = useState(() => createInitialCollapsedAllocationGroups(), ); - // Track expanded project sub-groups: key = "resourceId::projectId" const [expandedSubGroups, setExpandedSubGroups] = useState>(new Set()); const hasEvaluatedInitialVisibility = useRef(false); @@ -356,25 +341,6 @@ export function AllocationsClient() { setViewMode((prev) => (prev === "grouped" ? "flat" : "grouped")); }, [setViewMode]); - type ProjectSubGroup = { - projectId: string; - projectName: string; - projectCode: string; - allocations: AllocationWithDetails[]; - typicalHoursPerDay: number; - earliestStart: Date; - latestEnd: Date; - }; - - type AllocGroup = { - resourceId: string; - resourceName: string; - eid: string; - chapter: string | null; - allocations: AllocationWithDetails[]; - projectSubGroups: ProjectSubGroup[]; - }; - const groups = useMemo(() => { const map = new Map(); for (const alloc of sorted) { @@ -394,7 +360,6 @@ export function AllocationsClient() { group.allocations.push(alloc); } - // Build project sub-groups within each person group for (const group of map.values()) { const projMap = new Map(); for (const alloc of group.allocations) { @@ -410,7 +375,6 @@ export function AllocationsClient() { const first = allocs[0]!; let earliest = new Date(first.startDate); let latest = new Date(first.endDate); - // Find the most common hoursPerDay value across allocations const hpdCounts = new Map(); for (const a of allocs) { const s = new Date(a.startDate); @@ -419,7 +383,6 @@ export function AllocationsClient() { if (e > latest) latest = e; hpdCounts.set(a.hoursPerDay, (hpdCounts.get(a.hoursPerDay) ?? 0) + 1); } - // Pick the most frequent hoursPerDay; fall back to first let typicalH = first.hoursPerDay; let maxCount = 0; for (const [h, count] of hpdCounts) { @@ -580,116 +543,6 @@ export function AllocationsClient() { // colSpan for empty/loading states: checkbox + visible columns + actions const totalColSpan = 1 + visibleColumns.length + 1; - function renderAllocRow(alloc: AllocationWithDetails, isGrouped = false, rowIndex = 0) { - const isSelected = selection.selectedIds.has(alloc.id); - const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300"; - return ( - - - selection.toggle(alloc.id)} - className="rounded border-gray-300 dark:border-gray-600" - /> - - {visibleColumns.map((col) => { - switch (col.key) { - case "resource": - return ( - - {isGrouped ? ( - - ) : ( - (alloc.resource?.displayName ?? "—") - )} - - ); - case "project": - return ( - - {alloc.project ? ( - <> - {alloc.project.shortCode}{" "} - {alloc.project.name} - - ) : ( - "—" - )} - - ); - case "role": - return ( - - {alloc.role} - - ); - case "dates": - return ( - - {formatPeriod(alloc)} - - ); - case "hoursPerDay": - return ( - - {alloc.hoursPerDay}h - - ); - case "cost": - return ( - - {(alloc.dailyCostCents / 100).toFixed(0)} € - - ); - case "status": - return ( - - - {alloc.status} - - - ); - default: - return ( - - — - - ); - } - })} - -
- - -
- - - ); - } - return (
renderAllocRow(alloc, false, index))} + sorted.map((alloc, index) => ( + selection.toggle(alloc.id)} + onEdit={() => openEdit(alloc)} + onRequestDelete={() => setConfirmDelete({ single: alloc })} + deleteDisabled={singleDeletePending} + formatPeriod={formatPeriod} + /> + ))} - {!isLoading && - !allocationQueryFailure && - viewMode === "grouped" && - groups.map((group) => { - const isCollapsed = - collapsedGroups === "all" || collapsedGroups.has(group.resourceId); - const groupAllocIds = group.allocations.map((a) => a.id); - const allGroupSelected = selection.isAllSelected(groupAllocIds); - const groupIndeterminate = - !allGroupSelected && groupAllocIds.some((id) => selection.selectedIds.has(id)); - return ( - - {/* Group header */} - toggleGroup(group.resourceId)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleGroup(group.resourceId); - } - }} - tabIndex={0} - role="button" - aria-expanded={!isCollapsed} - > - e.stopPropagation()}> - { - if (el) el.indeterminate = groupIndeterminate; - }} - onChange={() => selection.toggleAll(groupAllocIds)} - className="rounded border-gray-300 dark:border-gray-600" - /> - - -
- - {isCollapsed ? "▸" : "▾"} - - - {group.resourceName} - - {group.eid && ( - - {group.eid} - - )} - {group.chapter && ( - - · {group.chapter} - - )} - - {group.allocations.length} - -
- - - {/* 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) { - return ( - - {renderAllocRow(subGroup.allocations[0]!, true, 0)} - - ); - } - - // Multiple allocations — show collapsible project sub-group - return ( - - toggleSubGroup(group.resourceId, subGroup.projectId)} - tabIndex={0} - role="button" - aria-expanded={isSubExpanded} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleSubGroup(group.resourceId, subGroup.projectId); - } - }} - > - e.stopPropagation()}> - a.id), - )} - ref={(el) => { - if (el) { - const ids = subGroup.allocations.map((a) => a.id); - el.indeterminate = - !selection.isAllSelected(ids) && - ids.some((id) => selection.selectedIds.has(id)); - } - }} - onChange={() => - selection.toggleAll(subGroup.allocations.map((a) => a.id)) - } - className="rounded border-gray-300 dark:border-gray-600" - /> - - -
- - {isSubExpanded ? "▾" : "▸"} - - - {subGroup.projectCode} - - - {subGroup.projectName} - - - {formatDate(subGroup.earliestStart)} →{" "} - {formatDate(subGroup.latestEnd)} - - - {subGroup.typicalHoursPerDay}h/day - - - {subGroup.allocations.length} - -
- - - {isSubExpanded && - subGroup.allocations.map((alloc, idx) => - renderAllocRow(alloc, true, idx), - )} -
- ); - })} -
- ); - })} + {!isLoading && !allocationQueryFailure && viewMode === "grouped" && ( + setConfirmDelete({ single: alloc })} + deleteDisabled={singleDeletePending} + formatPeriod={formatPeriod} + /> + )}
- {!isLoading && filteredDemands.length > 0 && ( -
-
-
-

- Open Demands -

-

- Placeholder demand rows not yet assigned to a resource. -

-
- - {filteredDemands.length} item{filteredDemands.length !== 1 ? "s" : ""} - -
-
- {filteredDemands.map((demand) => ( -
-
-
- {demand.project ? ( - <> - {demand.project.shortCode}{" "} - {demand.project.name} - - ) : ( - "Unknown project" - )} -
-
- {demand.role ?? "Placeholder role"} · {formatPeriod(demand)} ·{" "} - {demand.hoursPerDay}h/day -
-
-
-
-
- Unfilled -
-
- {demand.unfilledHeadcount ?? demand.headcount} /{" "} - {demand.requestedHeadcount ?? demand.headcount} -
-
-
- - -
-
-
- ))} -
-
- )} - - {/* Batch Status Picker */} - {batchStatusPicker && ( -
setBatchStatusPicker(false)} - > -
e.stopPropagation()} - > -

- Set status for {selection.count} allocations -

-
- {ALL_ALLOC_STATUSES.map((s) => ( - - ))} -
-
-
- )} - - {/* Confirm single delete */} - {confirmDelete?.single && ( - { - handleSingleDelete(confirmDelete.single!); - setConfirmDelete(null); - }} - onCancel={() => setConfirmDelete(null)} + {!isLoading && ( + setConfirmDelete({ single: alloc })} + deleteDisabled={singleDeletePending} + formatPeriod={formatPeriod} /> )} - {/* Confirm batch delete */} - {confirmDelete?.ids && ( - { - batchDeleteMutation.mutate({ ids: confirmDelete.ids! }); - setConfirmDelete(null); - }} - onCancel={() => setConfirmDelete(null)} - /> - )} + setBatchStatusPicker(true)} + onCloseBatchStatusPicker={() => setBatchStatusPicker(false)} + batchStatusPending={batchStatusMutation.isPending} + onBatchStatusConfirm={(ids, status) => { + setConfirmBatchStatus({ ids, status }); + }} + confirmDelete={confirmDelete} + onSetConfirmDelete={setConfirmDelete} + onSingleDelete={handleSingleDelete} + onBatchDelete={(ids) => batchDeleteMutation.mutate({ ids })} + batchDeletePending={batchDeleteMutation.isPending} + showDateShiftModal={showDateShiftModal} + onOpenDateShiftModal={() => setShowDateShiftModal(true)} + onCloseDateShiftModal={() => setShowDateShiftModal(false)} + onDateShiftConfirm={(daysDelta) => + batchDateShiftMutation.mutate({ + allocationIds: selectedMutationIds, + daysDelta, + mode: "move", + }) + } + dateShiftPending={batchDateShiftMutation.isPending} + /> {/* Confirm batch status */} {confirmBatchStatus && ( @@ -1311,46 +949,6 @@ export function AllocationsClient() { /> )} - {/* Batch Action Bar */} - setBatchStatusPicker(true), - disabled: batchStatusMutation.isPending, - }, - { - label: "Shift Dates…", - onClick: () => setShowDateShiftModal(true), - disabled: batchDateShiftMutation.isPending, - }, - { - label: `Delete (${selection.count})`, - variant: "danger", - onClick: () => setConfirmDelete({ ids: selectedMutationIds }), - disabled: batchDeleteMutation.isPending, - }, - ]} - /> - - {/* Batch date shift modal */} - {showDateShiftModal && ( - - batchDateShiftMutation.mutate({ - allocationIds: selectedMutationIds, - daysDelta, - mode: "move", - }) - } - onClose={() => setShowDateShiftModal(false)} - /> - )} - {/* Modal */} {modalOpen && ( void; + onRequestDelete: (demand: AllocationWithDetails) => void; + deleteDisabled: boolean; + formatPeriod: (alloc: AllocationWithDetails) => string; +}; + +export function OpenDemandsPanel({ + demands, + onEdit, + onRequestDelete, + deleteDisabled, + formatPeriod, +}: OpenDemandsPanelProps) { + if (demands.length === 0) return null; + + return ( +
+
+
+

Open Demands

+

+ Placeholder demand rows not yet assigned to a resource. +

+
+ + {demands.length} item{demands.length !== 1 ? "s" : ""} + +
+
+ {demands.map((demand) => ( +
+
+
+ {demand.project ? ( + <> + {demand.project.shortCode}{" "} + {demand.project.name} + + ) : ( + "Unknown project" + )} +
+
+ {demand.role ?? "Placeholder role"} · {formatPeriod(demand)} · {demand.hoursPerDay} + h/day +
+
+
+
+
+ Unfilled +
+
+ {demand.unfilledHeadcount ?? demand.headcount} /{" "} + {demand.requestedHeadcount ?? demand.headcount} +
+
+
+ + +
+
+
+ ))} +
+
+ ); +}