feat(ux): Sprint 1 — quick wins: EmptyState, DateRangePresets, debounce, save feedback, scenarios nav
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { ScenariosListClient } from "~/components/scenarios/ScenariosListClient.js";
|
||||
|
||||
export default function ScenariosPage() {
|
||||
return <ScenariosListClient />;
|
||||
}
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
|
||||
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date <span className="text-red-500">*</span><InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
@@ -439,7 +452,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <span className="text-red-500">*</span><InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
@@ -450,6 +463,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours/Day + Status */}
|
||||
|
||||
@@ -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<CollapsedAllocationGroups>(
|
||||
() => 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 && (
|
||||
<tr>
|
||||
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<div data-testid="allocations-empty-state" className="flex flex-col items-center gap-2">
|
||||
<p className="font-medium text-gray-700 dark:text-gray-200">{emptyState.title}</p>
|
||||
<p>{emptyState.detail}</p>
|
||||
{emptyState.showResetAction && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
Show all assignments
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<td colSpan={totalColSpan}>
|
||||
{emptyState.showResetAction ? (
|
||||
<EmptyState
|
||||
testId="allocations-empty-state"
|
||||
title={emptyState.title}
|
||||
detail={emptyState.detail}
|
||||
action={{ label: "Show all assignments", onClick: clearAll }}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
testId="allocations-empty-state"
|
||||
title={emptyState.title}
|
||||
detail={emptyState.detail}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -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 && <AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />}
|
||||
<SuccessToast show={saveStatus === "saved"} message="Layout saved" variant="info" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ function SettingsIcon() {
|
||||
function WebhooksIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>;
|
||||
}
|
||||
function ScenariosIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>;
|
||||
}
|
||||
|
||||
function CollapseIcon({ collapsed }: { collapsed: boolean }) {
|
||||
return (
|
||||
@@ -162,6 +165,7 @@ const navSections: NavSection[] = [
|
||||
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/scenarios", label: "Scenarios", icon: <ScenariosIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/notifications", label: "Notifications", icon: <NotificationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="app-page space-y-6">
|
||||
<div className="app-page-header">
|
||||
<div>
|
||||
<h1 className="app-page-title">Scenario Planning</h1>
|
||||
<p className="app-page-subtitle mt-1">
|
||||
Explore what-if staffing scenarios for any project without committing changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-surface overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="h-4 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-4 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-4 w-24 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No projects yet"
|
||||
detail="Create a project first, then return here to plan scenarios."
|
||||
action={{ label: "Go to Projects", onClick: () => { window.location.href = "/projects"; } }}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-800 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<th className="px-4 py-3 text-left">Code</th>
|
||||
<th className="px-4 py-3 text-left">Project</th>
|
||||
<th className="px-4 py-3 text-left">Client</th>
|
||||
<th className="px-4 py-3 text-left">Status</th>
|
||||
<th className="px-4 py-3 text-left">Start</th>
|
||||
<th className="px-4 py-3 text-left">End</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50 dark:divide-gray-800/50">
|
||||
{projects.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="group hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{p.shortCode}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{p.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{p.clientId ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${PROJECT_STATUS_BADGE[p.status] ?? PROJECT_STATUS_BADGE["DRAFT"]}`}>
|
||||
{p.status.charAt(0) + p.status.slice(1).toLowerCase().replace("_", " ")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{p.startDate ? formatDate(new Date(p.startDate)) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{p.endDate ? formatDate(new Date(p.endDate)) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link
|
||||
href={`/projects/${p.id}/scenario`}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 opacity-0 group-hover:opacity-100 transition hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Open Scenario
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`flex flex-wrap gap-1.5 ${className ?? ""}`}>
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.start, p.end)}
|
||||
className="rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium text-gray-600 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 dark:hover:bg-gray-700"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex flex-col items-center gap-2 py-12 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{icon && (
|
||||
<div className="mb-1 text-gray-300 dark:text-gray-600">{icon}</div>
|
||||
)}
|
||||
<p className="font-medium text-gray-700 dark:text-gray-200">{title}</p>
|
||||
{detail && <p>{detail}</p>}
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="mt-1 rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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,
|
||||
|
||||
Reference in New Issue
Block a user