diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index eeb6d29..4a1acea 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; import { formatDate } from "~/lib/format.js"; import type { Project, ColumnDef } from "@planarchy/shared"; import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared"; @@ -80,7 +81,9 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) { const utils = trpc.useUtils(); - const dropdownRef = useRef(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const [pos, setPos] = useState({ top: 0, left: 0 }); const updateStatus = trpc.project.updateStatus.useMutation({ onSuccess: async () => { @@ -89,18 +92,29 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project }, }); + // Position the portal dropdown below the trigger button + useEffect(() => { + if (!isOpen || !triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + setPos({ top: rect.bottom + 4, left: rect.left }); + }, [isOpen]); + useEffect(() => { if (!isOpen) return; function handleOutsideClick(e: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) onClose(); + const target = e.target as Node; + if (triggerRef.current?.contains(target)) return; + if (panelRef.current?.contains(target)) return; + onClose(); } document.addEventListener("mousedown", handleOutsideClick); return () => document.removeEventListener("mousedown", handleOutsideClick); }, [isOpen, onClose]); return ( -
+ <> - {isOpen && ( -
+ {isOpen && createPortal( +
{ALL_STATUSES.map((s) => ( ))} -
+
, + document.body, )} -
+ ); } @@ -182,6 +201,34 @@ export function ProjectsClient() { }, }); + // ─── Favorites ────────────────────────────────────────────────────────── + const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 }); + const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]); + const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({ + onMutate: async ({ projectId }) => { + // Cancel any outgoing refetches so they don't overwrite optimistic update + await utils.user.getFavoriteProjectIds.cancel(); + // Snapshot previous value + const previous = utils.user.getFavoriteProjectIds.getData(); + // Optimistically update the cache + const current = previous ?? []; + const next = current.includes(projectId) + ? current.filter((id: string) => id !== projectId) + : [...current, projectId]; + utils.user.getFavoriteProjectIds.setData(undefined, next); + return { previous }; + }, + onError: (_err, _vars, context) => { + // Rollback on error + if (context?.previous !== undefined) { + utils.user.getFavoriteProjectIds.setData(undefined, context.previous); + } + }, + onSettled: () => { + void utils.user.getFavoriteProjectIds.invalidate(); + }, + }); + // ─── Custom field columns from global blueprints ────────────────────────── const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery( { target: BlueprintTarget.PROJECT }, @@ -490,6 +537,7 @@ export function ProjectsClient() { {/* Drag handle column */} + reorder(draggedId, project.id)} className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} > + + +

{project.name}

-
-
- {formatDate(project.startDate)} - {" — "} - {formatDate(project.endDate)} +
+
+
+ {formatDate(project.startDate)} + {" — "} + {formatDate(project.endDate)} +
+
Win probability: {project.winProbability}%
-
Win probability: {project.winProbability}%
+
@@ -169,7 +174,7 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro {assignment.hoursPerDay}h - {(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} € + {(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} € - {/* Open demands table */} -
-
-

- Open Demands ({project.demands.length}) -

-
- - - - - - - - - - - - - {project.demands.map((demand) => ( - - - - - - - - - ))} - -
- Role - - Period - - Requested - - Unfilled - - Hours/Day - - Status -
- {demand.roleEntity?.name ?? demand.role ?? "Unassigned"} - - {formatDate(demand.startDate)} - {" → "} - {formatDate(demand.endDate)} - {demand.requestedHeadcount}{demand.unfilledHeadcount}{demand.hoursPerDay}h - - {demand.status} - -
- {project.demands.length === 0 && ( -
No open demands for this project.
- )} -
+ {/* Open demands table (client component with fill action) */} +
); } diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx index 60fcca9..3d5b02e 100644 --- a/apps/web/src/components/admin/UsersClient.tsx +++ b/apps/web/src/components/admin/UsersClient.tsx @@ -65,9 +65,24 @@ type EditState = { chapterIds: string; }; +type CreateState = { + name: string; + email: string; + password: string; + systemRole: SystemRole; +}; + +const EMPTY_CREATE: CreateState = { + name: "", + email: "", + password: "", + systemRole: SystemRole.USER, +}; + export function UsersClient() { const [selectedUserId, setSelectedUserId] = useState(null); const [editState, setEditState] = useState(null); + const [createState, setCreateState] = useState(null); const [actionError, setActionError] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState(""); @@ -101,6 +116,15 @@ export function UsersClient() { onError: (err) => setActionError(err.message), }); + const createUserMutation = trpc.user.create.useMutation({ + onSuccess: async () => { + await utils.user.list.invalidate(); + setCreateState(null); + setActionError(null); + }, + onError: (err) => setActionError(err.message), + }); + const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({ onSuccess: async () => { await utils.user.list.invalidate(); @@ -221,10 +245,22 @@ export function UsersClient() { const selectedUser = editState ? allUsers.find((u) => u.id === editState.userId) : null; + async function handleCreateUser() { + if (!createState) return; + setActionError(null); + await createUserMutation.mutateAsync({ + name: createState.name, + email: createState.email, + password: createState.password, + systemRole: createState.systemRole, + }); + } + const isPending = updateRoleMutation.isPending || setPermissionsMutation.isPending || - resetPermissionsMutation.isPending; + resetPermissionsMutation.isPending || + createUserMutation.isPending; function clearAll() { setSearch(""); @@ -245,6 +281,16 @@ export function UsersClient() { Manage user roles and permission overrides

+ {/* Filters */} @@ -350,6 +396,106 @@ 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" + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ )} + {/* Edit Modal */} {editState && selectedUser && (
diff --git a/apps/web/src/components/allocations/AllocationModal.tsx b/apps/web/src/components/allocations/AllocationModal.tsx index 146e526..2f1e198 100644 --- a/apps/web/src/components/allocations/AllocationModal.tsx +++ b/apps/web/src/components/allocations/AllocationModal.tsx @@ -36,6 +36,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo const [roleId, setRoleId] = useState(allocation?.roleId ?? ""); const [roleFreeText, setRoleFreeText] = useState(allocation?.role ?? ""); const [headcount, setHeadcount] = useState(allocation?.headcount ?? 1); + const [budgetEur, setBudgetEur] = useState(() => { + const cents = (allocation as { budgetCents?: number } | undefined)?.budgetCents ?? 0; + return cents > 0 ? String(cents / 100) : ""; + }); const [startDate, setStartDate] = useState(toDateInputValue(allocation?.startDate)); const [endDate, setEndDate] = useState(toDateInputValue(allocation?.endDate)); const [hoursPerDay, setHoursPerDay] = useState(allocation?.hoursPerDay ?? 8); @@ -172,6 +176,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo role: roleString, roleId: roleId || undefined, headcount: isDemandEntry ? headcount : 1, + ...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}), startDate: start, endDate: end, hoursPerDay, @@ -186,6 +191,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo role: roleString, roleId: roleId || undefined, headcount, + ...(budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}), startDate: start, endDate: end, hoursPerDay, @@ -270,16 +276,30 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{isDemandEntry && ( -
- - setHeadcount(Math.max(1, Number(e.target.value)))} - min={1} - max={50} - className="w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm text-center dark:bg-gray-900 dark:text-gray-100" - /> +
+
+ + setHeadcount(Math.max(1, Number(e.target.value)))} + min={1} + max={50} + className="w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm text-center dark:bg-gray-900 dark:text-gray-100" + /> +
+
+ + setBudgetEur(e.target.value)} + min={0} + step={100} + placeholder="0" + className="w-28 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm dark:bg-gray-900 dark:text-gray-100" + /> +
)}
diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 38c4ba0..f887e18 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { formatDate } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; import { AllocationModal } from "./AllocationModal.js"; @@ -22,6 +22,11 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; +/** Fragment wrapper for grouped rows — avoids unnecessary DOM nodes */ +function GroupRows({ children }: { children: React.ReactNode }) { + return <>{children}; +} + const ALL_ALLOC_STATUSES = [ { value: "PROPOSED", label: "Proposed" }, { value: "CONFIRMED", label: "Confirmed" }, @@ -168,6 +173,146 @@ export function AllocationsClient() { [allocationMutationIdsByDisplayId, selection.selectedArray], ); + // ─── View mode: grouped (default) vs flat ────────────────────────────────── + const [viewMode, setViewMode] = useState<"grouped" | "flat">(() => { + if (typeof window === "undefined") return "grouped"; + return (localStorage.getItem("planarchy:allocations:viewMode") as "grouped" | "flat") ?? "grouped"; + }); + const [collapsedGroups, setCollapsedGroups] = useState | "all">("all"); + // Track expanded project sub-groups: key = "resourceId::projectId" + const [expandedSubGroups, setExpandedSubGroups] = useState>(new Set()); + + const toggleViewMode = useCallback(() => { + setViewMode((prev) => { + const next = prev === "grouped" ? "flat" : "grouped"; + localStorage.setItem("planarchy:allocations:viewMode", next); + return next; + }); + }, []); + + 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) { + const rid = alloc.resource?.id ?? "__unassigned__"; + let group = map.get(rid); + if (!group) { + group = { + resourceId: rid, + resourceName: alloc.resource?.displayName ?? "Unassigned", + eid: alloc.resource?.eid ?? "", + chapter: (alloc.resource as { chapter?: string | null } | undefined)?.chapter ?? null, + allocations: [], + projectSubGroups: [], + }; + map.set(rid, group); + } + 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) { + const pid = alloc.project?.id ?? "__no_project__"; + let list = projMap.get(pid); + if (!list) { list = []; projMap.set(pid, list); } + list.push(alloc); + } + group.projectSubGroups = [...projMap.entries()].map(([pid, allocs]) => { + 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); + const e = new Date(a.endDate); + if (s < earliest) earliest = s; + 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) { + if (count > maxCount) { typicalH = h; maxCount = count; } + } + return { + projectId: pid, + projectName: first.project?.name ?? "Unknown", + projectCode: first.project?.shortCode ?? "", + allocations: allocs, + typicalHoursPerDay: typicalH, + earliestStart: earliest, + latestEnd: latest, + }; + }); + group.projectSubGroups.sort((a, b) => a.projectName.localeCompare(b.projectName)); + } + + const arr = [...map.values()]; + arr.sort((a, b) => { + if (a.resourceId === "__unassigned__") return 1; + if (b.resourceId === "__unassigned__") return -1; + return a.resourceName.localeCompare(b.resourceName); + }); + return arr; + }, [sorted]); + + const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]); + + const toggleGroup = useCallback((resourceId: string) => { + setCollapsedGroups((prev) => { + // "all" → expand just this one (materialize all IDs minus clicked) + if (prev === "all") { + const next = new Set(groupIds); + next.delete(resourceId); + return next; + } + const next = new Set(prev); + if (next.has(resourceId)) next.delete(resourceId); + else next.add(resourceId); + return next; + }); + }, [groupIds]); + + const collapseAll = useCallback(() => { + setCollapsedGroups("all"); + }, []); + + const expandAll = useCallback(() => { + setCollapsedGroups(new Set()); + }, []); + + const toggleSubGroup = useCallback((resourceId: string, projectId: string) => { + const key = `${resourceId}::${projectId}`; + setExpandedSubGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + function handleSort(field: string) { if (field === "resource") { toggle("resource", (a) => a.resource?.displayName ?? null); @@ -213,6 +358,74 @@ export function AllocationsClient() { const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending; + // colSpan for empty/loading states: checkbox + visible columns + actions + const totalColSpan = 1 + visibleColumns.length + 1; + + function renderAllocRow(alloc: AllocationWithDetails, isGrouped = false) { + const isSelected = selection.selectedIds.has(alloc.id); + 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 (
@@ -311,6 +524,42 @@ export function AllocationsClient() { onSetVisible={setVisible} defaultKeys={defaultKeys} /> + + {/* View mode toggle */} +
+ + +
+ + {viewMode === "grouped" && groups.length > 1 && ( +
+ + | + +
+ )} {/* Filter chips */} @@ -363,75 +612,128 @@ export function AllocationsClient() { {isLoading && ( - Loading allocations… + Loading allocations… )} {!isLoading && sorted.length === 0 && ( - No assignments found. + No assignments found. )} - {!isLoading && - sorted.map((alloc) => { - const isSelected = selection.selectedIds.has(alloc.id); + {!isLoading && viewMode === "flat" && + sorted.map((alloc) => renderAllocRow(alloc))} + + {!isLoading && 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 ( - - - selection.toggle(alloc.id)} - className="rounded border-gray-300 dark:border-gray-600" - /> - - {visibleColumns.map((col) => { - switch (col.key) { - case "resource": - return {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 —; + + {/* 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)}; } + + // 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) => renderAllocRow(alloc, true))} +
+ ); })} - -
- - -
- - +
); })} diff --git a/apps/web/src/components/allocations/FillOpenDemandModal.tsx b/apps/web/src/components/allocations/FillOpenDemandModal.tsx index a4b8928..3aa7e70 100644 --- a/apps/web/src/components/allocations/FillOpenDemandModal.tsx +++ b/apps/web/src/components/allocations/FillOpenDemandModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useRef, useState, useMemo, useCallback } from "react"; import { AllocationStatus } from "@planarchy/shared"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; @@ -17,6 +17,7 @@ interface OpenDemandAllocation { startDate: Date | string; endDate: Date | string; hoursPerDay: number; + budgetCents?: number; roleEntity?: { id: string; name: string; color: string | null } | null; project?: { id: string; name: string; shortCode: string }; } @@ -27,159 +28,478 @@ interface FillOpenDemandModalProps { onSuccess: () => void; } -function formatDate(date: Date | string): string { +/** A planned (not yet submitted) resource assignment. */ +interface PlannedResource { + resourceId: string; + resourceName: string; + eid: string; + hoursPerDay: number; + availableHours: number; + availableDays: number; + conflictDays: number; + coveragePercent: number; + estimatedCostCents: number; +} + +function fmtDate(date: Date | string): string { const d = typeof date === "string" ? new Date(date) : date; return d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" }); } export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) { + // ─── Phase: "plan" (select resources) → "confirm" (review & submit) ── + const [phase, setPhase] = useState<"plan" | "confirm">("plan"); + const [planned, setPlanned] = useState([]); + + // Planning phase state const [resourceId, setResourceId] = useState(""); const [hoursPerDay, setHoursPerDay] = useState(allocation.hoursPerDay); const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); const [serverError, setServerError] = useState(null); + // Submit phase state + const [submitting, setSubmitting] = useState(false); + const [submitProgress, setSubmitProgress] = useState(0); + const panelRef = useRef(null); useFocusTrap(panelRef, true); + const searchTimerRef = useRef>(undefined); + function handleSearchChange(value: string) { + setSearch(value); + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200); + } + const utils = trpc.useUtils(); - const invalidatePlanningViews = async () => { + const invalidatePlanningViews = useCallback(async () => { await utils.allocation.list.invalidate(); await utils.allocation.listView.invalidate(); await utils.timeline.getEntries.invalidate(); await utils.timeline.getEntriesView.invalidate(); await utils.timeline.getProjectContext.invalidate(); await utils.timeline.getBudgetStatus.invalidate(); - }; + }, [utils]); const { data: resources } = trpc.resource.list.useQuery( - { isActive: true, search: search || undefined, limit: 100 }, + { isActive: true, search: debouncedSearch || undefined, limit: 50 }, { staleTime: 15_000 }, - ) as { data: { resources: Array<{ id: string; displayName: string; eid: string }> } | undefined }; + ) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined }; - const fillOpenDemandMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation({ - onSuccess: async () => { - await invalidatePlanningViews(); - onSuccess(); + const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery( + { + resourceId, + startDate: new Date(allocation.startDate), + endDate: new Date(allocation.endDate), + hoursPerDay, }, - onError: (err) => setServerError(err.message), - }); + { enabled: !!resourceId && phase === "plan", staleTime: 10_000 }, + ); + + const fillMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation(); const roleName = allocation.roleEntity?.name ?? allocation.role ?? "Unknown Role"; const roleColor = allocation.roleEntity?.color ?? "#6366f1"; - const resourceList = resources?.resources ?? []; - const isPending = fillOpenDemandMutation.isPending; + const avail = availabilityQuery.data; - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!resourceId) { - setServerError("Please select a resource."); - return; + const totalDemandHours = useMemo(() => { + let workingDays = 0; + const d = new Date(allocation.startDate); + const end = new Date(allocation.endDate); + while (d <= end) { + if (d.getDay() !== 0 && d.getDay() !== 6) workingDays++; + d.setDate(d.getDate() + 1); } - fillOpenDemandMutation.mutate({ - allocationId: getPlanningEntryMutationId(allocation), - resourceId, + return workingDays * allocation.hoursPerDay; + }, [allocation.startDate, allocation.endDate, allocation.hoursPerDay]); + + const consumedHours = planned.reduce((sum, r) => sum + r.availableHours, 0); + const remainingHours = Math.max(0, totalDemandHours - consumedHours); + + // ─── Planning phase actions ────────────────────────────────────────── + function addToPlan() { + if (!resourceId || !avail) return; + const selectedResource = resourceList.find((r) => r.id === resourceId); + if (!selectedResource) return; + + // Estimated cost = LCR * available hours + const lcrCents = selectedResource.lcrCents ?? 0; + const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours); + + setPlanned((prev) => [...prev, { + resourceId: selectedResource.id, + resourceName: selectedResource.displayName, + eid: selectedResource.eid, hoursPerDay, - status: AllocationStatus.PROPOSED, - }); + availableHours: avail.totalAvailableHours, + availableDays: avail.availableDays, + conflictDays: avail.conflictDays, + coveragePercent: avail.coveragePercent, + estimatedCostCents, + }]); + + // Reset for next resource + setResourceId(""); + setSearch(""); + setDebouncedSearch(""); + setHoursPerDay(allocation.hoursPerDay); } + function removeFromPlan(rid: string) { + setPlanned((prev) => prev.filter((r) => r.resourceId !== rid)); + } + + // ─── Confirm phase: submit all planned resources sequentially ──────── + async function handleConfirmSubmit() { + setSubmitting(true); + setServerError(null); + setSubmitProgress(0); + + const allocationId = getPlanningEntryMutationId(allocation); + + for (let i = 0; i < planned.length; i++) { + const p = planned[i]!; + setSubmitProgress(i + 1); + try { + await fillMutation.mutateAsync({ + allocationId, + resourceId: p.resourceId, + hoursPerDay: p.hoursPerDay, + status: AllocationStatus.PROPOSED, + }); + } catch (err) { + setServerError(`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`); + setSubmitting(false); + return; + } + } + + await invalidatePlanningViews(); + setSubmitting(false); + onSuccess(); + } + + const isAlreadyPlanned = (rid: string) => planned.some((p) => p.resourceId === rid); + + // ─── Render ────────────────────────────────────────────────────────── return (
{ if (e.target === e.currentTarget) onClose(); }} + onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }} >
{ if (e.key === "Escape") onClose(); }} + className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4" + onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }} > + {/* Header */}
-

Assign Open Demand

- +

+ {phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"} +

+
-
+
{/* Demand summary */} -
+
-
+
{roleName}
- {allocation.project?.name} · {formatDate(allocation.startDate)} – {formatDate(allocation.endDate)} + {allocation.project?.name} · {fmtDate(allocation.startDate)} – {fmtDate(allocation.endDate)} +
+
+ {allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total + {allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR` : ""}
- {allocation.headcount > 1 && ( -
- {allocation.headcount} slots remaining — assigning one resource -
- )}
+ + {/* Coverage bar */} +
+
+ Demand coverage + {Math.round(consumedHours)}h / {totalDemandHours}h ({totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%) +
+
+ {planned.map((r, i) => ( +
+ ))} +
+ + {/* Planned resources list */} + {planned.length > 0 && ( +
+ {planned.map((r, i) => ( +
+
+ {r.resourceName} + ({r.eid}) + {r.hoursPerDay}h/day + {Math.round(r.availableHours)}h · {r.coveragePercent}% + {phase === "plan" && ( + + )} +
+ ))} + {remainingHours > 0 && ( +
+
+ Remaining: {Math.round(remainingHours)}h +
+ )} +
+ )} +
-
- {/* Resource search */} -
- - setSearch(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100" - /> -
- -
- - -
- -
- - setHoursPerDay(Number(e.target.value))} - min={0.5} - max={8} - step={0.5} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100" - /> -
- - {serverError && ( -
- {serverError} + {/* ─── PLAN PHASE: add resources ──────────────────────────────── */} + {phase === "plan" && ( +
+
+ + handleSearchChange(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100" + />
- )} -
- - +
+ + +
+ +
+ + setHoursPerDay(Number(e.target.value))} + min={0.5} + max={24} + step={0.5} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100" + /> +
+ + {/* Availability preview */} + {resourceId && avail && ( +
= 100 + ? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800" + : avail.coveragePercent >= 50 + ? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800" + : "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" + }`}> +
+ Availability: {avail.resource.name} +
+
+
+ Available +
{avail.availableDays} days
+
+
+ Conflicts +
{avail.conflictDays} days
+
+
+ Hours +
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
+
+
+ + {avail.existingAssignments.length > 0 && ( +
+
Existing bookings:
+ {avail.existingAssignments.slice(0, 4).map((a, i) => ( +
+ {a.code} · {a.hoursPerDay}h/day · {a.start} – {a.end} +
+ ))} + {avail.existingAssignments.length > 4 && ( +
+{avail.existingAssignments.length - 4} more
+ )} +
+ )} +
+ )} + + {resourceId && availabilityQuery.isLoading && ( +
Checking availability...
+ )} + + {/* Action buttons */} +
+ +
+ + {planned.length > 0 && ( + + )} +
+
- + )} + + {/* ─── CONFIRM PHASE: review & submit all ────────────────────── */} + {phase === "confirm" && ( +
+
+ + + + + + + + + + + + {planned.map((r) => ( + + + + + + + + ))} + + + + + + + + + {allocation.budgetCents && allocation.budgetCents > 0 && ( + + + + + + )} + +
Resourceh/dayHoursEst. CostCoverage
+ {r.resourceName} + {r.eid} + {r.hoursPerDay}h{Math.round(r.availableHours)}h{(r.estimatedCostCents / 100).toLocaleString("de-DE")} EUR + = 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}> + {r.coveragePercent}% + +
Total + + {Math.round(consumedHours)}h / {totalDemandHours}h + + {(planned.reduce((s, r) => s + r.estimatedCostCents, 0) / 100).toLocaleString("de-DE")} EUR + + {totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}% +
Role Budget: + {(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR + + {(() => { + const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0); + const remain = allocation.budgetCents! - totalCost; + return ( + + {remain < 0 ? `${(Math.abs(remain) / 100).toLocaleString("de-DE")} over` : `${(remain / 100).toLocaleString("de-DE")} left`} + + ); + })()} +
+
+ + {remainingHours > 0 && ( +
+ {Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially. +
+ )} + + {submitting && ( +
+ Assigning resource {submitProgress}/{planned.length}... +
+ )} + + {serverError && ( +
+ {serverError} +
+ )} + +
+ + +
+
+ )}
); diff --git a/apps/web/src/components/assistant/ChatDrawer.tsx b/apps/web/src/components/assistant/ChatDrawer.tsx new file mode 100644 index 0000000..3574a47 --- /dev/null +++ b/apps/web/src/components/assistant/ChatDrawer.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import { trpc } from "~/lib/trpc/client.js"; +import { ChatMessage, TypingIndicator } from "./ChatMessage.js"; + +/** Map route prefixes to human-readable page context for the AI */ +const ROUTE_CONTEXT: Record = { + "/dashboard": "Dashboard — Übersicht mit KPIs, aktive Projekte, Ressourcen-Auslastung", + "/timeline": "Timeline — Gantt-artige Ansicht aller Allokationen und Projekte", + "/allocations": "Allokationen — Liste aller Zuweisungen von Ressourcen zu Projekten", + "/staffing": "Staffing — Projektbesetzung und Kapazitätsplanung", + "/resources": "Ressourcen — Liste aller Mitarbeiter mit Details (FTE, LCR, Skills, Chapter)", + "/projects": "Projekte — Liste aller Projekte mit Budget, Status, Zeitraum", + "/roles": "Rollen — Verwaltung der verfügbaren Rollen", + "/estimates": "Estimating — Aufwandsschätzungen für Projekte", + "/vacations/my": "Meine Urlaube — Eigene Urlaubsanträge und Saldo", + "/vacations": "Urlaubsverwaltung — Alle Urlaubsanträge, Genehmigungen, Team-Kalender", + "/analytics/skills": "Skills Analytics — Skill-Verteilung und -Analyse über alle Ressourcen", + "/analytics/computation-graph": "Computation Graph — Berechnungsvisualisierung für Budget/Kosten", + "/reports/chargeability": "Chargeability Report — Auslastungsanalyse pro Ressource", + "/admin/settings": "Admin-Einstellungen — System-Konfiguration, AI-Credentials, SMTP", + "/admin/users": "Benutzerverwaltung — Rollen, Berechtigungen, Zugänge", +}; + +function resolvePageContext(pathname: string): string { + // Try exact match first, then prefix match (longest first) + const exact = ROUTE_CONTEXT[pathname]; + if (exact) return exact; + const sorted = Object.keys(ROUTE_CONTEXT).sort((a, b) => b.length - a.length); + for (const prefix of sorted) { + const ctx = ROUTE_CONTEXT[prefix]; + if (pathname.startsWith(prefix) && ctx) return ctx; + } + return pathname; +} + +interface Message { + role: "user" | "assistant"; + content: string; +} + +export function ChatDrawer({ onClose }: { onClose: () => void }) { + const pathname = usePathname(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const chatMutation = trpc.assistant.chat.useMutation(); + + // Auto-scroll to bottom on new messages + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages, isLoading]); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const sendMessage = useCallback(async () => { + const text = input.trim(); + if (!text || isLoading) return; + + setInput(""); + setError(null); + + const userMsg: Message = { role: "user", content: text }; + const updated = [...messages, userMsg]; + setMessages(updated); + setIsLoading(true); + + try { + const reply = await chatMutation.mutateAsync({ + messages: updated.map((m) => ({ role: m.role, content: m.content })), + ...(pathname ? { pageContext: resolvePageContext(pathname) } : {}), + }); + setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]); + } catch (err) { + const msg = err instanceof Error ? err.message : "Something went wrong"; + setError(msg); + } finally { + setIsLoading(false); + } + }, [input, isLoading, messages, chatMutation]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void sendMessage(); + } + }; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+
+ + + +
+

Planarchy Assistant

+
+ +
+ + {/* Messages */} +
+ {messages.length === 0 && !isLoading && ( +
+ + + +

Frag mich etwas!

+

z.B. "Welche Ressourcen gibt es?" oder "Budget von Z033T593?"

+
+ )} + {messages.map((msg, i) => ( + + ))} + {isLoading && } + {error && ( +
+ {error} +
+ )} +
+ + {/* Input */} +
+
+