Pull out period selection popover into its own component

This commit is contained in:
2025-09-22 12:49:13 -04:00
parent 4776a112b6
commit d8b39979cd
2 changed files with 293 additions and 235 deletions

View File

@@ -7,7 +7,6 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -45,17 +44,11 @@ import {
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle } from "lucide-react";
Popover, import PeriodSelectionPopover, {
PopoverContent, type QuickPreset,
PopoverTrigger, } from "@/components/dashboard/PeriodSelectionPopover";
} from "@/components/ui/popover"; import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle, Calendar } from "lucide-react";
import type { CustomPeriod } from "@/utils/naturalLanguagePeriod";
import {
generateNaturalLanguagePreview,
parseNaturalLanguagePeriod,
} from "@/utils/naturalLanguagePeriod";
type TrendDirection = "up" | "down" | "flat"; type TrendDirection = "up" | "down" | "flat";
@@ -634,8 +627,6 @@ const FinancialOverview = () => {
}); });
const [isLast30DaysMode, setIsLast30DaysMode] = useState<boolean>(true); const [isLast30DaysMode, setIsLast30DaysMode] = useState<boolean>(true);
const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = useState<boolean>(false); const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = useState<boolean>(false);
const [naturalLanguageInput, setNaturalLanguageInput] = useState<string>("");
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({ const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
income: true, income: true,
cogs: true, cogs: true,
@@ -1040,62 +1031,19 @@ const FinancialOverview = () => {
[series]: !prev[series], [series]: !prev[series],
})); }));
}; };
const handleNaturalLanguageResult = (result: NaturalLanguagePeriodResult) => {
if (result === "last30days") {
const suggestions = [
"last 30 days",
"this month",
"last month",
"this quarter",
"last quarter",
"this year",
"last year",
"last 3 months",
"last 6 months",
"last 2 quarters",
"Q1 2024",
"q1-q3 24",
"q1 24 - q2 25",
"January 2024",
"jan-24",
"jan-may 24",
"2023",
"2021-2023",
"21-23",
"January to March 2024",
"jan 2023 - may 2024"
];
const filteredSuggestions = suggestions.filter(suggestion =>
suggestion.toLowerCase().includes(naturalLanguageInput.toLowerCase()) &&
suggestion.toLowerCase() !== naturalLanguageInput.toLowerCase()
);
const handleNaturalLanguageChange = (value: string) => {
setNaturalLanguageInput(value);
setShowSuggestions(value.length > 0);
};
const handleNaturalLanguageSubmit = (input: string) => {
const parsed = parseNaturalLanguagePeriod(input, currentDate);
if (parsed === "last30days") {
setIsLast30DaysMode(true); setIsLast30DaysMode(true);
setIsPeriodPopoverOpen(false); return;
} else if (parsed) { }
setIsLast30DaysMode(false);
setCustomPeriod(parsed); if (result) {
setIsPeriodPopoverOpen(false); setIsLast30DaysMode(false);
setCustomPeriod(result);
} }
setNaturalLanguageInput("");
setShowSuggestions(false);
}; };
const handleSuggestionClick = (suggestion: string) => { const handleQuickPeriod = (preset: QuickPreset) => {
setNaturalLanguageInput(suggestion);
handleNaturalLanguageSubmit(suggestion);
};
const handleQuickPeriod = (preset: string) => {
const now = new Date(); const now = new Date();
const currentYear = now.getFullYear(); const currentYear = now.getFullYear();
const currentMonth = now.getMonth(); const currentMonth = now.getMonth();
@@ -1176,175 +1124,16 @@ const FinancialOverview = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!error && ( {!error && (
<> <>
<Popover open={isPeriodPopoverOpen} onOpenChange={setIsPeriodPopoverOpen}> <PeriodSelectionPopover
<PopoverTrigger asChild> open={isPeriodPopoverOpen}
<Button variant="outline" className="h-9"> onOpenChange={setIsPeriodPopoverOpen}
<Calendar className="w-4 h-4 mr-2" /> selectedLabel={selectedRangeLabel}
{selectedRangeLabel} referenceDate={currentDate}
</Button> isLast30DaysActive={isLast30DaysMode}
</PopoverTrigger> onQuickSelect={handleQuickPeriod}
<PopoverContent className="w-96 p-4" align="end"> onApplyResult={handleNaturalLanguageResult}
<div className="space-y-4">
<div className="text-sm font-medium">Select Time Period</div>
{/* Quick Presets - Compact Grid */}
<div className="grid grid-cols-3 gap-2">
<Button
variant={isLast30DaysMode ? "default" : "outline"}
size="sm"
onClick={() => {
handleQuickPeriod("last30days");
setIsPeriodPopoverOpen(false);
}}
className="h-8 text-xs"
>
Last 30 Days
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
handleQuickPeriod("thisMonth");
setIsPeriodPopoverOpen(false);
}}
className="h-8 text-xs"
>
This Month
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
handleQuickPeriod("lastMonth");
setIsPeriodPopoverOpen(false);
}}
className="h-8 text-xs"
>
Last Month
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
handleQuickPeriod("thisQuarter");
setIsPeriodPopoverOpen(false);
}}
className="h-8 text-xs"
>
This Quarter
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
handleQuickPeriod("lastQuarter");
setIsPeriodPopoverOpen(false);
}}
className="h-8 text-xs"
>
Last Quarter
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
handleQuickPeriod("thisYear");
setIsPeriodPopoverOpen(false);
}}
className="h-8 text-xs"
>
This Year
</Button>
</div>
<Separator />
{/* Natural Language Input */}
<div className="space-y-3">
<div className="text-xs text-muted-foreground">Or type a custom period</div>
<div className="relative">
<Input
placeholder="e.g., jan-may 24, 2021-2023, Q1-Q3 2024"
value={naturalLanguageInput}
onChange={(e) => handleNaturalLanguageChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleNaturalLanguageSubmit(naturalLanguageInput);
}
if (e.key === 'Escape') {
setNaturalLanguageInput("");
setShowSuggestions(false);
}
}}
className="h-8 text-sm"
/> />
{/* Live Preview */}
{naturalLanguageInput && (
<div className="mt-2">
{(() => {
const parsed = parseNaturalLanguagePeriod(naturalLanguageInput, currentDate);
const preview = generateNaturalLanguagePreview(parsed);
if (preview) {
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Recognized as:</span>
<span className="font-medium text-green-600 dark:text-green-400">
{preview}
</span>
</div>
);
} else {
return (
<div className="text-xs text-amber-600 dark:text-amber-400">
Not recognized - try a different format
</div>
);
}
})()}
</div>
)}
{/* Suggestions Dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 border rounded-md shadow-lg max-h-32 overflow-y-auto">
{filteredSuggestions.slice(0, 6).map((suggestion, index) => (
<button
key={index}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
</div>
{/* Example suggestions when input is empty */}
{naturalLanguageInput === "" && (
<div className="text-xs text-muted-foreground">
<div className="mb-1">Examples:</div>
<div className="flex flex-wrap gap-1">
{suggestions.slice(0, 6).map((suggestion, index) => (
<button
key={index}
className="px-2 py-0.5 bg-muted hover:bg-muted/80 rounded text-xs transition-colors"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</button>
))}
</div>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}> <Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
@@ -1650,7 +1439,7 @@ function FinancialStatGrid({
}>; }>;
}) { }) {
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
{cards.map((card) => ( {cards.map((card) => (
<FinancialStatCard key={card.key} title={card.title} value={card.value} description={card.description} trend={card.trend} accentClass={card.accentClass} /> <FinancialStatCard key={card.key} title={card.title} value={card.value} description={card.description} trend={card.trend} accentClass={card.accentClass} />
))} ))}

View File

@@ -0,0 +1,269 @@
import { useMemo, useState, type KeyboardEventHandler } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "lucide-react";
import {
generateNaturalLanguagePreview,
parseNaturalLanguagePeriod,
type NaturalLanguagePeriodResult,
} from "@/utils/naturalLanguagePeriod";
export type QuickPreset =
| "last30days"
| "thisMonth"
| "lastMonth"
| "thisQuarter"
| "lastQuarter"
| "thisYear";
const SUGGESTIONS = [
"last 30 days",
"this month",
"last month",
"this quarter",
"last quarter",
"this year",
"last year",
"last 3 months",
"last 6 months",
"last 2 quarters",
"Q1 2024",
"q1-q3 24",
"q1 24 - q2 25",
"January 2024",
"jan-24",
"jan-may 24",
"2023",
"2021-2023",
"21-23",
"January to March 2024",
"jan 2023 - may 2024",
];
interface PeriodSelectionPopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedLabel: string;
referenceDate: Date;
isLast30DaysActive: boolean;
onQuickSelect: (preset: QuickPreset) => void;
onApplyResult: (result: NaturalLanguagePeriodResult) => void;
}
const PeriodSelectionPopover = ({
open,
onOpenChange,
selectedLabel,
referenceDate,
isLast30DaysActive,
onQuickSelect,
onApplyResult,
}: PeriodSelectionPopoverProps) => {
const [inputValue, setInputValue] = useState("");
const [showSuggestions, setShowSuggestions] = useState(false);
const filteredSuggestions = useMemo(() => {
if (!inputValue) {
return SUGGESTIONS;
}
return SUGGESTIONS.filter((suggestion) =>
suggestion.toLowerCase().includes(inputValue.toLowerCase()) &&
suggestion.toLowerCase() !== inputValue.toLowerCase()
);
}, [inputValue]);
const preview = useMemo(() => {
if (!inputValue) {
return { label: null, parsed: null } as const;
}
const parsed = parseNaturalLanguagePeriod(inputValue, referenceDate);
return {
parsed,
label: generateNaturalLanguagePreview(parsed),
} as const;
}, [inputValue, referenceDate]);
const closePopover = () => {
onOpenChange(false);
};
const resetInput = () => {
setInputValue("");
setShowSuggestions(false);
};
const applyResult = (value: string) => {
const parsed = parseNaturalLanguagePeriod(value, referenceDate);
if (!parsed) {
return;
}
onApplyResult(parsed);
resetInput();
closePopover();
};
const handleInputChange = (value: string) => {
setInputValue(value);
setShowSuggestions(value.length > 0);
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === "Enter") {
event.preventDefault();
applyResult(inputValue);
}
if (event.key === "Escape") {
resetInput();
}
};
const handleSuggestionClick = (suggestion: string) => {
setInputValue(suggestion);
applyResult(suggestion);
};
const handleQuickSelect = (preset: QuickPreset) => {
onQuickSelect(preset);
resetInput();
closePopover();
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9">
<Calendar className="w-4 h-4 mr-2" />
{selectedLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-4" align="end">
<div className="space-y-4">
<div className="text-sm font-medium">Select Time Period</div>
<div className="grid grid-cols-3 gap-2">
<Button
variant={isLast30DaysActive ? "default" : "outline"}
size="sm"
onClick={() => handleQuickSelect("last30days")}
className="h-8 text-xs"
>
Last 30 Days
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickSelect("thisMonth")}
className="h-8 text-xs"
>
This Month
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickSelect("lastMonth")}
className="h-8 text-xs"
>
Last Month
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickSelect("thisQuarter")}
className="h-8 text-xs"
>
This Quarter
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickSelect("lastQuarter")}
className="h-8 text-xs"
>
Last Quarter
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickSelect("thisYear")}
className="h-8 text-xs"
>
This Year
</Button>
</div>
<Separator />
<div className="space-y-3">
<div className="text-xs text-muted-foreground">Or type a custom period</div>
<div className="relative">
<Input
placeholder="e.g., jan-may 24, 2021-2023, Q1-Q3 2024"
value={inputValue}
onChange={(event) => handleInputChange(event.target.value)}
onKeyDown={handleKeyDown}
className="h-8 text-sm"
/>
{inputValue && (
<div className="mt-2">
{preview.label ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Recognized as:</span>
<span className="font-medium text-green-600 dark:text-green-400">
{preview.label}
</span>
</div>
) : (
<div className="text-xs text-amber-600 dark:text-amber-400">
Not recognized - try a different format
</div>
)}
</div>
)}
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 border rounded-md shadow-lg max-h-32 overflow-y-auto">
{filteredSuggestions.slice(0, 6).map((suggestion) => (
<button
key={suggestion}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
{inputValue === "" && (
<div className="text-xs text-muted-foreground mt-2">
<div className="mb-1">Examples:</div>
<div className="flex flex-wrap gap-1">
{SUGGESTIONS.slice(0, 6).map((suggestion) => (
<button
key={suggestion}
className="px-2 py-0.5 bg-muted hover:bg-muted/80 rounded text-xs transition-colors"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default PeriodSelectionPopover;