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:
2026-04-09 13:08:19 +02:00
parent a16c41e739
commit 6831e199c6
9 changed files with 272 additions and 35 deletions
@@ -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>
);
}
+36
View File
@@ -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>
);
}