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"; } }}
+ />
+ ) : (
+
+
+
+ | Code |
+ Project |
+ Client |
+ Status |
+ Start |
+ End |
+ |
+
+
+
+ {projects.map((p) => (
+
+ |
+ {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,