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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user