From 6831e199c61f3195fff81644a9b8553eb804a3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 13:08:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(ux):=20Sprint=201=20=E2=80=94=20quick=20wi?= =?UTF-8?q?ns:=20EmptyState,=20DateRangePresets,=20debounce,=20save=20feed?= =?UTF-8?q?back,=20scenarios=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmptyState shared component; replace AllocationsClient inline empty state - DateRangePresets (this month/quarter/3 months/year) integrated into AllocationModal - Debounce conflict-check inputs in AllocationModal (400ms) using existing useDebounce - Dashboard layout save feedback via SuccessToast after DB write completes - Scenarios nav item in Planning sidebar + /scenarios list page Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/(app)/scenarios/page.tsx | 5 + .../allocations/AllocationModal.tsx | 32 +++-- .../allocations/AllocationsClient.tsx | 44 ++++--- .../components/dashboard/DashboardClient.tsx | 4 +- apps/web/src/components/layout/AppShell.tsx | 4 + .../scenarios/ScenariosListClient.tsx | 111 ++++++++++++++++++ .../src/components/ui/DateRangePresets.tsx | 59 ++++++++++ apps/web/src/components/ui/EmptyState.tsx | 36 ++++++ apps/web/src/hooks/useDashboardLayout.ts | 12 +- 9 files changed, 272 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/app/(app)/scenarios/page.tsx create mode 100644 apps/web/src/components/scenarios/ScenariosListClient.tsx create mode 100644 apps/web/src/components/ui/DateRangePresets.tsx create mode 100644 apps/web/src/components/ui/EmptyState.tsx diff --git a/apps/web/src/app/(app)/scenarios/page.tsx b/apps/web/src/app/(app)/scenarios/page.tsx new file mode 100644 index 0000000..13f12af --- /dev/null +++ b/apps/web/src/app/(app)/scenarios/page.tsx @@ -0,0 +1,5 @@ +import { ScenariosListClient } from "~/components/scenarios/ScenariosListClient.js"; + +export default function ScenariosPage() { + return ; +} diff --git a/apps/web/src/components/allocations/AllocationModal.tsx b/apps/web/src/components/allocations/AllocationModal.tsx index 9d05130..7b9de63 100644 --- a/apps/web/src/components/allocations/AllocationModal.tsx +++ b/apps/web/src/components/allocations/AllocationModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; +import { useDebounce } from "~/hooks/useDebounce.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { AllocationStatus } from "@capakraken/shared"; @@ -9,6 +10,7 @@ import { trpc } from "~/lib/trpc/client.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { toDateInputValue } from "~/lib/format.js"; import { DateInput } from "~/components/ui/DateInput.js"; +import { DateRangePresets } from "~/components/ui/DateRangePresets.js"; import { RecurrenceEditor } from "./RecurrenceEditor.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { ConflictWarningPanel } from "./ConflictWarningPanel.js"; @@ -71,22 +73,28 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo { enabled: shouldCheckOverlap, staleTime: 30_000 }, ); + // Debounce conflict-check inputs so we don't fire on every keystroke/interaction. + const debouncedResourceId = useDebounce(resourceId, 400); + const debouncedStartDate = useDebounce(startDate, 400); + const debouncedEndDate = useDebounce(endDate, 400); + const debouncedHoursPerDay = useDebounce(hoursPerDay, 400); + // Pre-flight conflict check: overbooking + vacation overlap for this resource/period. - const conflictCheckStart = startDate ? new Date(startDate) : null; - const conflictCheckEnd = endDate ? new Date(endDate) : null; + const conflictCheckStart = debouncedStartDate ? new Date(debouncedStartDate) : null; + const conflictCheckEnd = debouncedEndDate ? new Date(debouncedEndDate) : null; const shouldCheckConflicts = !isDemandEntry && - !!resourceId && + !!debouncedResourceId && conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) && conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) && - hoursPerDay > 0; + debouncedHoursPerDay > 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)( { - resourceId, + resourceId: debouncedResourceId, startDate: conflictCheckStart, endDate: conflictCheckEnd, - hoursPerDay, + hoursPerDay: debouncedHoursPerDay, excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined, }, { enabled: shouldCheckConflicts, staleTime: 15_000 }, @@ -424,10 +432,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo {/* Dates */} -
+
+
+ Date Range * + { setStartDate(s); setEndDate(e); }} /> +
+
+
{/* Hours/Day + Status */} diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 69f844f..c72d8e5 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { useLocalStorage } from "~/hooks/useLocalStorage.js"; import { formatDate } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; import { AllocationModal } from "./AllocationModal.js"; @@ -22,6 +23,7 @@ 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 { collapseAllAllocationGroups, createInitialCollapsedAllocationGroups, @@ -240,10 +242,7 @@ export function AllocationsClient() { ); // ─── View mode: grouped (default) vs flat ────────────────────────────────── - const [viewMode, setViewMode] = useState<"grouped" | "flat">(() => { - if (typeof window === "undefined") return "grouped"; - return (localStorage.getItem("capakraken:allocations:viewMode") as "grouped" | "flat") ?? "grouped"; - }); + const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">("capakraken:allocations:viewMode", "grouped"); const [collapsedGroups, setCollapsedGroups] = useState( () => createInitialCollapsedAllocationGroups(), ); @@ -252,12 +251,8 @@ export function AllocationsClient() { const hasEvaluatedInitialVisibility = useRef(false); const toggleViewMode = useCallback(() => { - setViewMode((prev) => { - const next = prev === "grouped" ? "flat" : "grouped"; - localStorage.setItem("capakraken:allocations:viewMode", next); - return next; - }); - }, []); + setViewMode((prev) => prev === "grouped" ? "flat" : "grouped"); + }, [setViewMode]); type ProjectSubGroup = { projectId: string; @@ -750,20 +745,21 @@ export function AllocationsClient() { {!isLoading && !allocationQueryFailure && sorted.length === 0 && ( - -
-

{emptyState.title}

-

{emptyState.detail}

- {emptyState.showResetAction && ( - - )} -
+ + {emptyState.showResetAction ? ( + + ) : ( + + )} )} diff --git a/apps/web/src/components/dashboard/DashboardClient.tsx b/apps/web/src/components/dashboard/DashboardClient.tsx index 019c405..e3a947b 100644 --- a/apps/web/src/components/dashboard/DashboardClient.tsx +++ b/apps/web/src/components/dashboard/DashboardClient.tsx @@ -8,6 +8,7 @@ import { useDashboardLayout } from "~/hooks/useDashboardLayout.js"; import { WidgetContainer } from "./WidgetContainer.js"; import { AddWidgetModal } from "./AddWidgetModal.js"; import { getWidget } from "./widget-registry.js"; +import { SuccessToast } from "~/components/ui/SuccessToast.js"; // Import CSS for react-grid-layout import "react-grid-layout/css/styles.css"; @@ -146,7 +147,7 @@ function DeferredWidgetBody({ export function DashboardClient() { const [addModalOpen, setAddModalOpen] = useState(false); - const { config, isHydrated, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } = + const { config, isHydrated, saveStatus, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } = useDashboardLayout(); // Measure grid container width so Responsive knows the column size. @@ -331,6 +332,7 @@ export function DashboardClient() { )} {addModalOpen && setAddModalOpen(false)} />} +
); } diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 7864a78..e6f12df 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -121,6 +121,9 @@ function SettingsIcon() { function WebhooksIcon() { return ; } +function ScenariosIcon() { + return ; +} function CollapseIcon({ collapsed }: { collapsed: boolean }) { return ( @@ -162,6 +165,7 @@ const navSections: NavSection[] = [ { href: "/timeline", label: "Timeline", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, { href: "/allocations", label: "Allocations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/staffing", label: "Staffing", icon: , roles: ["ADMIN", "MANAGER"] }, + { href: "/scenarios", label: "Scenarios", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/notifications", label: "Notifications", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, ], }, diff --git a/apps/web/src/components/scenarios/ScenariosListClient.tsx b/apps/web/src/components/scenarios/ScenariosListClient.tsx new file mode 100644 index 0000000..d3bc37a --- /dev/null +++ b/apps/web/src/components/scenarios/ScenariosListClient.tsx @@ -0,0 +1,111 @@ +"use client"; + +import Link from "next/link"; +import { trpc } from "~/lib/trpc/client.js"; +import { EmptyState } from "~/components/ui/EmptyState.js"; +import { formatDate } from "~/lib/format.js"; + +const PROJECT_STATUS_BADGE: Record = { + ACTIVE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300", + DRAFT: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", + ON_HOLD: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", + COMPLETED: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", + CANCELLED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", +}; + +export function ScenariosListClient() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, isLoading } = (trpc.project.list.useQuery as any)({ limit: 500 }, { staleTime: 60_000 }) as { + data: { projects: Array<{ id: string; shortCode: string; name: string; status: string; startDate: string | null; endDate: string | null; clientId: string | null }> } | undefined; + isLoading: boolean; + }; + + const projects = data?.projects ?? []; + + return ( +
+
+
+

Scenario Planning

+

+ Explore what-if staffing scenarios for any project without committing changes. +

+
+
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : projects.length === 0 ? ( + { window.location.href = "/projects"; } }} + /> + ) : ( + + + + + + + + + + + + + {projects.map((p) => ( + + + + + + + + + + ))} + +
CodeProjectClientStatusStartEnd +
+ {p.shortCode} + + {p.name} + + {p.clientId ?? "—"} + + + {p.status.charAt(0) + p.status.slice(1).toLowerCase().replace("_", " ")} + + + {p.startDate ? formatDate(new Date(p.startDate)) : "—"} + + {p.endDate ? formatDate(new Date(p.endDate)) : "—"} + + + Open Scenario + + + + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/ui/DateRangePresets.tsx b/apps/web/src/components/ui/DateRangePresets.tsx new file mode 100644 index 0000000..a158bc3 --- /dev/null +++ b/apps/web/src/components/ui/DateRangePresets.tsx @@ -0,0 +1,59 @@ +"use client"; + +interface DateRangePresetsProps { + onSelect: (start: string, end: string) => void; + className?: string; +} + +function toIso(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function getPresets(): { label: string; start: string; end: string }[] { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); // 0-based + + // This month + const monthStart = new Date(y, m, 1); + const monthEnd = new Date(y, m + 1, 0); + + // This quarter + const q = Math.floor(m / 3); + const quarterStart = new Date(y, q * 3, 1); + const quarterEnd = new Date(y, q * 3 + 3, 0); + + // Next 3 months + const next3Start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const next3End = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate() - 1); + + // This year + const yearStart = new Date(y, 0, 1); + const yearEnd = new Date(y, 11, 31); + + return [ + { label: "This month", start: toIso(monthStart), end: toIso(monthEnd) }, + { label: "This quarter", start: toIso(quarterStart), end: toIso(quarterEnd) }, + { label: "Next 3 months", start: toIso(next3Start), end: toIso(next3End) }, + { label: "This year", start: toIso(yearStart), end: toIso(yearEnd) }, + ]; +} + +export function DateRangePresets({ onSelect, className }: DateRangePresetsProps) { + const presets = getPresets(); + + return ( +
+ {presets.map((p) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/ui/EmptyState.tsx b/apps/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..0b54e68 --- /dev/null +++ b/apps/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; + +interface EmptyStateProps { + icon?: ReactNode; + title: string; + detail?: string; + action?: { + label: string; + onClick: () => void; + }; + testId?: string; +} + +export function EmptyState({ icon, title, detail, action, testId }: EmptyStateProps) { + return ( +
+ {icon && ( +
{icon}
+ )} +

{title}

+ {detail &&

{detail}

} + {action && ( + + )} +
+ ); +} diff --git a/apps/web/src/hooks/useDashboardLayout.ts b/apps/web/src/hooks/useDashboardLayout.ts index d515a31..217d568 100644 --- a/apps/web/src/hooks/useDashboardLayout.ts +++ b/apps/web/src/hooks/useDashboardLayout.ts @@ -89,7 +89,16 @@ export function useDashboardLayout() { staleTime: 30_000, }) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined }; - const saveMutation = trpc.user.saveDashboardLayout.useMutation(); + const [saveStatus, setSaveStatus] = useState<"idle" | "saved">("idle"); + const saveStatusTimerRef = useRef | null>(null); + + const saveMutation = trpc.user.saveDashboardLayout.useMutation({ + onSuccess: () => { + setSaveStatus("saved"); + if (saveStatusTimerRef.current) clearTimeout(saveStatusTimerRef.current); + saveStatusTimerRef.current = setTimeout(() => setSaveStatus("idle"), 2500); + }, + }); // Sync from DB on load (DB wins if it has data). useEffect(() => { @@ -237,6 +246,7 @@ export function useDashboardLayout() { return { config, isHydrated, + saveStatus, addWidget, removeWidget, updateWidgetConfig,