Compare commits
2 Commits
2ff325a132
...
d8b39979cd
| Author | SHA1 | Date | |
|---|---|---|---|
| d8b39979cd | |||
| 4776a112b6 |
@@ -3,7 +3,6 @@ import { acotService } from "@/services/dashboard/acotService";
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -47,6 +45,10 @@ 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 { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle } from "lucide-react";
|
import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle } from "lucide-react";
|
||||||
|
import PeriodSelectionPopover, {
|
||||||
|
type QuickPreset,
|
||||||
|
} from "@/components/dashboard/PeriodSelectionPopover";
|
||||||
|
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
||||||
|
|
||||||
type TrendDirection = "up" | "down" | "flat";
|
type TrendDirection = "up" | "down" | "flat";
|
||||||
|
|
||||||
@@ -110,28 +112,6 @@ type FinancialResponse = {
|
|||||||
trend: FinancialTrendPoint[];
|
trend: FinancialTrendPoint[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type MonthPeriod = {
|
|
||||||
type: "month";
|
|
||||||
startYear: number;
|
|
||||||
startMonth: number;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QuarterPeriod = {
|
|
||||||
type: "quarter";
|
|
||||||
startYear: number;
|
|
||||||
startQuarter: number;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type YearPeriod = {
|
|
||||||
type: "year";
|
|
||||||
startYear: number;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomPeriod = MonthPeriod | QuarterPeriod | YearPeriod;
|
|
||||||
|
|
||||||
type ChartSeriesKey = "income" | "cogs" | "profit" | "margin";
|
type ChartSeriesKey = "income" | "cogs" | "profit" | "margin";
|
||||||
|
|
||||||
type GroupByOption = "day" | "month" | "quarter" | "year";
|
type GroupByOption = "day" | "month" | "quarter" | "year";
|
||||||
@@ -232,26 +212,6 @@ function formatPeriodRangeLabel(period: CustomPeriod): string {
|
|||||||
return period.count === 1 ? `${startYear}` : `${startYear} – ${endYear}`;
|
return period.count === 1 ? `${startYear}` : `${startYear} – ${endYear}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEndPeriodLabel(period: CustomPeriod, count: number): string {
|
|
||||||
const safe = ensureValidCustomPeriod(period);
|
|
||||||
switch (safe.type) {
|
|
||||||
case "month": {
|
|
||||||
const end = new Date(safe.startYear, safe.startMonth + count - 1, 1);
|
|
||||||
return formatMonthLabel(end.getFullYear(), end.getMonth());
|
|
||||||
}
|
|
||||||
case "quarter": {
|
|
||||||
const startMonth = safe.startQuarter * 3;
|
|
||||||
const end = new Date(safe.startYear, startMonth + (count - 1) * 3, 1);
|
|
||||||
const endQuarter = Math.floor(end.getMonth() / 3);
|
|
||||||
return formatQuarterLabel(end.getFullYear(), endQuarter);
|
|
||||||
}
|
|
||||||
case "year":
|
|
||||||
default: {
|
|
||||||
const endYear = safe.startYear + count - 1;
|
|
||||||
return `${endYear}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCurrency = (value: number, minimumFractionDigits = 0) => {
|
const formatCurrency = (value: number, minimumFractionDigits = 0) => {
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
@@ -599,10 +559,6 @@ const buildComparisonFromValues = (current?: number | null, previous?: number |
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateYearOptions = (span: number) => {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
return Array.from({ length: span }, (_, index) => currentYear - index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureValidCustomPeriod = (period: CustomPeriod): CustomPeriod => {
|
const ensureValidCustomPeriod = (period: CustomPeriod): CustomPeriod => {
|
||||||
if (period.count < 1) {
|
if (period.count < 1) {
|
||||||
@@ -663,13 +619,14 @@ const FinancialOverview = () => {
|
|||||||
const currentDate = useMemo(() => new Date(), []);
|
const currentDate = useMemo(() => new Date(), []);
|
||||||
const currentYear = currentDate.getFullYear();
|
const currentYear = currentDate.getFullYear();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<"rolling" | "custom">("rolling");
|
|
||||||
const [customPeriod, setCustomPeriod] = useState<CustomPeriod>({
|
const [customPeriod, setCustomPeriod] = useState<CustomPeriod>({
|
||||||
type: "month",
|
type: "month",
|
||||||
startYear: currentYear,
|
startYear: currentYear,
|
||||||
startMonth: currentDate.getMonth(),
|
startMonth: currentDate.getMonth(),
|
||||||
count: 1,
|
count: 1,
|
||||||
});
|
});
|
||||||
|
const [isLast30DaysMode, setIsLast30DaysMode] = useState<boolean>(true);
|
||||||
|
const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = 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,
|
||||||
@@ -683,7 +640,7 @@ const FinancialOverview = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedRange = useMemo(() => {
|
const selectedRange = useMemo(() => {
|
||||||
if (viewMode === "rolling") {
|
if (isLast30DaysMode) {
|
||||||
const end = new Date(currentDate);
|
const end = new Date(currentDate);
|
||||||
const start = new Date(currentDate);
|
const start = new Date(currentDate);
|
||||||
start.setHours(0, 0, 0, 0);
|
start.setHours(0, 0, 0, 0);
|
||||||
@@ -693,7 +650,7 @@ const FinancialOverview = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return computePeriodRange(customPeriod);
|
return computePeriodRange(customPeriod);
|
||||||
}, [viewMode, customPeriod, currentDate]);
|
}, [isLast30DaysMode, customPeriod, currentDate]);
|
||||||
|
|
||||||
const effectiveRangeEnd = useMemo(() => {
|
const effectiveRangeEnd = useMemo(() => {
|
||||||
if (!selectedRange) {
|
if (!selectedRange) {
|
||||||
@@ -721,7 +678,6 @@ const FinancialOverview = () => {
|
|||||||
};
|
};
|
||||||
}, [selectedRange, effectiveRangeEnd]);
|
}, [selectedRange, effectiveRangeEnd]);
|
||||||
|
|
||||||
const yearOptions = useMemo(() => generateYearOptions(12), []);
|
|
||||||
|
|
||||||
const rawTrendPoints = useMemo<RawTrendPoint[]>(() => {
|
const rawTrendPoints = useMemo<RawTrendPoint[]>(() => {
|
||||||
if (!data?.trend?.length) {
|
if (!data?.trend?.length) {
|
||||||
@@ -827,7 +783,7 @@ const FinancialOverview = () => {
|
|||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
if (viewMode === "rolling") {
|
if (isLast30DaysMode) {
|
||||||
params.timeRange = "last30days";
|
params.timeRange = "last30days";
|
||||||
} else {
|
} else {
|
||||||
if (!selectedRange || !requestRange) {
|
if (!selectedRange || !requestRange) {
|
||||||
@@ -875,7 +831,7 @@ const FinancialOverview = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [viewMode, selectedRange, requestRange]);
|
}, [isLast30DaysMode, selectedRange, requestRange]);
|
||||||
|
|
||||||
const cards = useMemo(
|
const cards = useMemo(
|
||||||
() => {
|
() => {
|
||||||
@@ -990,7 +946,7 @@ const FinancialOverview = () => {
|
|||||||
}, [aggregatedPoints]);
|
}, [aggregatedPoints]);
|
||||||
|
|
||||||
const selectedRangeLabel = useMemo(() => {
|
const selectedRangeLabel = useMemo(() => {
|
||||||
if (viewMode === "rolling") {
|
if (isLast30DaysMode) {
|
||||||
return "Last 30 Days";
|
return "Last 30 Days";
|
||||||
}
|
}
|
||||||
const label = formatPeriodRangeLabel(customPeriod);
|
const label = formatPeriodRangeLabel(customPeriod);
|
||||||
@@ -1014,7 +970,7 @@ const FinancialOverview = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return `${label} (through ${partialLabel})`;
|
return `${label} (through ${partialLabel})`;
|
||||||
}, [viewMode, customPeriod, selectedRange, effectiveRangeEnd]);
|
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
|
||||||
|
|
||||||
const marginDomain = useMemo<[number, number]>(() => {
|
const marginDomain = useMemo<[number, number]>(() => {
|
||||||
if (!metrics.margin || !chartData.length) {
|
if (!metrics.margin || !chartData.length) {
|
||||||
@@ -1062,119 +1018,12 @@ const FinancialOverview = () => {
|
|||||||
|
|
||||||
const enableAutoGrouping = () => setGroupByAuto(true);
|
const enableAutoGrouping = () => setGroupByAuto(true);
|
||||||
|
|
||||||
const handleViewModeChange = (mode: "rolling" | "custom") => {
|
|
||||||
enableAutoGrouping();
|
|
||||||
setViewMode(mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGroupByChange = (value: string) => {
|
const handleGroupByChange = (value: string) => {
|
||||||
setGroupBy(value as GroupByOption);
|
setGroupBy(value as GroupByOption);
|
||||||
setGroupByAuto(false);
|
setGroupByAuto(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomTypeChange = (value: string) => {
|
|
||||||
const nextType = value as CustomPeriod["type"];
|
|
||||||
setViewMode("custom");
|
|
||||||
enableAutoGrouping();
|
|
||||||
setCustomPeriod((prev) => {
|
|
||||||
const safePrev = ensureValidCustomPeriod(prev);
|
|
||||||
switch (nextType) {
|
|
||||||
case "month": {
|
|
||||||
const startMonth = safePrev.type === "month" ? safePrev.startMonth : currentDate.getMonth();
|
|
||||||
return {
|
|
||||||
type: "month",
|
|
||||||
startYear: safePrev.startYear,
|
|
||||||
startMonth,
|
|
||||||
count: Math.min(safePrev.count, MONTH_COUNT_LIMIT),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "quarter": {
|
|
||||||
const startQuarter =
|
|
||||||
safePrev.type === "quarter"
|
|
||||||
? safePrev.startQuarter
|
|
||||||
: Math.floor(currentDate.getMonth() / 3);
|
|
||||||
return {
|
|
||||||
type: "quarter",
|
|
||||||
startYear: safePrev.startYear,
|
|
||||||
startQuarter,
|
|
||||||
count: Math.min(Math.max(Math.ceil(safePrev.count / 3), 1), QUARTER_COUNT_LIMIT),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "year":
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
type: "year",
|
|
||||||
startYear: safePrev.startYear,
|
|
||||||
count: Math.min(Math.max(Math.ceil(safePrev.count / 12), 1), YEAR_COUNT_LIMIT),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartYearChange = (value: string) => {
|
|
||||||
const nextYear = Number(value);
|
|
||||||
enableAutoGrouping();
|
|
||||||
setCustomPeriod((prev) => {
|
|
||||||
const safePrev = ensureValidCustomPeriod(prev);
|
|
||||||
switch (safePrev.type) {
|
|
||||||
case "month":
|
|
||||||
return { ...safePrev, startYear: nextYear };
|
|
||||||
case "quarter":
|
|
||||||
return { ...safePrev, startYear: nextYear };
|
|
||||||
case "year":
|
|
||||||
default:
|
|
||||||
return { ...safePrev, startYear: nextYear };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartMonthChange = (value: string) => {
|
|
||||||
const nextMonth = Number(value);
|
|
||||||
enableAutoGrouping();
|
|
||||||
setCustomPeriod((prev) => {
|
|
||||||
if (prev.type !== "month") {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return { ...prev, startMonth: nextMonth };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartQuarterChange = (value: string) => {
|
|
||||||
const nextQuarter = Number(value);
|
|
||||||
enableAutoGrouping();
|
|
||||||
setCustomPeriod((prev) => {
|
|
||||||
if (prev.type !== "quarter") {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return { ...prev, startQuarter: nextQuarter };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCountChange = (value: string) => {
|
|
||||||
const nextCount = Number(value);
|
|
||||||
enableAutoGrouping();
|
|
||||||
setCustomPeriod((prev) => {
|
|
||||||
const safePrev = ensureValidCustomPeriod(prev);
|
|
||||||
return ensureValidCustomPeriod({ ...safePrev, count: nextCount });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const endPeriodOptions = useMemo(() => {
|
|
||||||
const safePrev = ensureValidCustomPeriod(customPeriod);
|
|
||||||
const limit = safePrev.type === "month"
|
|
||||||
? MONTH_COUNT_LIMIT
|
|
||||||
: safePrev.type === "quarter"
|
|
||||||
? QUARTER_COUNT_LIMIT
|
|
||||||
: YEAR_COUNT_LIMIT;
|
|
||||||
|
|
||||||
return Array.from({ length: limit }, (_, index) => {
|
|
||||||
const count = index + 1;
|
|
||||||
return {
|
|
||||||
count,
|
|
||||||
label: formatEndPeriodLabel(safePrev, count),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [customPeriod]);
|
|
||||||
|
|
||||||
const toggleMetric = (series: ChartSeriesKey) => {
|
const toggleMetric = (series: ChartSeriesKey) => {
|
||||||
setMetrics((prev) => ({
|
setMetrics((prev) => ({
|
||||||
@@ -1182,37 +1031,134 @@ const FinancialOverview = () => {
|
|||||||
[series]: !prev[series],
|
[series]: !prev[series],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
const handleNaturalLanguageResult = (result: NaturalLanguagePeriodResult) => {
|
||||||
|
if (result === "last30days") {
|
||||||
|
setIsLast30DaysMode(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setIsLast30DaysMode(false);
|
||||||
|
setCustomPeriod(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickPeriod = (preset: QuickPreset) => {
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth();
|
||||||
|
const currentQuarter = Math.floor(currentMonth / 3);
|
||||||
|
|
||||||
|
enableAutoGrouping();
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "last30days":
|
||||||
|
// For Last 30 Days, we keep the special mode but this is temporary
|
||||||
|
// The UI will show this as selected but the period inputs won't reflect it
|
||||||
|
setIsLast30DaysMode(true);
|
||||||
|
break;
|
||||||
|
case "thisMonth":
|
||||||
|
setIsLast30DaysMode(false);
|
||||||
|
setCustomPeriod({
|
||||||
|
type: "month",
|
||||||
|
startYear: currentYear,
|
||||||
|
startMonth: currentMonth,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "lastMonth":
|
||||||
|
setIsLast30DaysMode(false);
|
||||||
|
const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1;
|
||||||
|
const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear;
|
||||||
|
setCustomPeriod({
|
||||||
|
type: "month",
|
||||||
|
startYear: lastMonthYear,
|
||||||
|
startMonth: lastMonth,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "thisQuarter":
|
||||||
|
setIsLast30DaysMode(false);
|
||||||
|
setCustomPeriod({
|
||||||
|
type: "quarter",
|
||||||
|
startYear: currentYear,
|
||||||
|
startQuarter: currentQuarter,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "lastQuarter":
|
||||||
|
setIsLast30DaysMode(false);
|
||||||
|
const lastQuarter = currentQuarter === 0 ? 3 : currentQuarter - 1;
|
||||||
|
const lastQuarterYear = currentQuarter === 0 ? currentYear - 1 : currentYear;
|
||||||
|
setCustomPeriod({
|
||||||
|
type: "quarter",
|
||||||
|
startYear: lastQuarterYear,
|
||||||
|
startQuarter: lastQuarter,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "thisYear":
|
||||||
|
setIsLast30DaysMode(false);
|
||||||
|
setCustomPeriod({
|
||||||
|
type: "year",
|
||||||
|
startYear: currentYear,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const canShowDetails = !error;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||||
<CardHeader className="p-6 pb-4">
|
<CardHeader className="p-6 pb-4">
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Financial Overview
|
Profit & Loss Overview
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-muted-foreground mt-1">
|
|
||||||
{data?.dateRange?.label || "Key financial metrics for your selected period"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
{canShowDetails ? (
|
<div className="flex items-center gap-2">
|
||||||
detailRows.length ? (
|
{!error && (
|
||||||
|
<>
|
||||||
|
<PeriodSelectionPopover
|
||||||
|
open={isPeriodPopoverOpen}
|
||||||
|
onOpenChange={setIsPeriodPopoverOpen}
|
||||||
|
selectedLabel={selectedRangeLabel}
|
||||||
|
referenceDate={currentDate}
|
||||||
|
isLast30DaysActive={isLast30DaysMode}
|
||||||
|
onQuickSelect={handleQuickPeriod}
|
||||||
|
onApplyResult={handleNaturalLanguageResult}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" disabled={loading}>
|
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
|
||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||||
<DialogHeader className="flex-none">
|
<DialogHeader className="flex-none">
|
||||||
<DialogTitle className="text-gray-900 dark:text-gray-100">Financial Details</DialogTitle>
|
<DialogTitle className="text-gray-900 dark:text-gray-100">
|
||||||
<DialogDescription>
|
Financial Details
|
||||||
Explore the grouped breakdown for the selected metrics.
|
</DialogTitle>
|
||||||
</DialogDescription>
|
<div className="flex items-center justify-center gap-2 pt-4">
|
||||||
<div className="flex flex-wrap justify-center gap-2 pt-4">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
|
<Button
|
||||||
|
key={series.key}
|
||||||
|
variant={metrics[series.key] ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleMetric(series.key)}
|
||||||
|
>
|
||||||
|
{series.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" className="h-6" />
|
||||||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||||
<SelectTrigger className="w-[140px]">
|
<SelectTrigger className="w-[140px]">
|
||||||
<SelectValue placeholder="Group By" />
|
<SelectValue placeholder="Group By" />
|
||||||
@@ -1225,63 +1171,61 @@ const FinancialOverview = () => {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{SERIES_DEFINITIONS.map((series) => (
|
|
||||||
<Button
|
|
||||||
key={series.key}
|
|
||||||
variant={metrics[series.key] ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => toggleMetric(series.key)}
|
|
||||||
>
|
|
||||||
{series.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Separator className="my-4" />
|
<div className="flex-1 overflow-auto mt-6">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full">
|
||||||
{detailRows.length ? (
|
<Table className="w-full">
|
||||||
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="px-6 py-3 whitespace-nowrap text-center">Date</TableHead>
|
<TableHead className="text-center whitespace-nowrap px-6">
|
||||||
|
Date
|
||||||
|
</TableHead>
|
||||||
{metrics.income && (
|
{metrics.income && (
|
||||||
<TableHead className="px-6 py-3 whitespace-nowrap text-center">Total Income</TableHead>
|
<TableHead className="text-center whitespace-nowrap px-6">
|
||||||
|
Total Income
|
||||||
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{metrics.cogs && (
|
{metrics.cogs && (
|
||||||
<TableHead className="px-6 py-3 whitespace-nowrap text-center">COGS</TableHead>
|
<TableHead className="text-center whitespace-nowrap px-6">
|
||||||
|
COGS
|
||||||
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{metrics.profit && (
|
{metrics.profit && (
|
||||||
<TableHead className="px-6 py-3 whitespace-nowrap text-center">Gross Profit</TableHead>
|
<TableHead className="text-center whitespace-nowrap px-6">
|
||||||
|
Gross Profit
|
||||||
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{metrics.margin && (
|
{metrics.margin && (
|
||||||
<TableHead className="px-6 py-3 whitespace-nowrap text-center">Margin</TableHead>
|
<TableHead className="text-center whitespace-nowrap px-6">
|
||||||
|
Margin
|
||||||
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{detailRows.map((row) => (
|
{detailRows.map((row) => (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell className="px-6 py-3 whitespace-nowrap text-center text-sm text-muted-foreground">
|
<TableCell className="text-center whitespace-nowrap px-6">
|
||||||
{row.label || "—"}
|
{row.label || "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{metrics.income && (
|
{metrics.income && (
|
||||||
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
|
<TableCell className="text-center whitespace-nowrap px-6">
|
||||||
{row.isFuture ? "—" : formatCurrency(row.income, 0)}
|
{row.isFuture ? "—" : formatCurrency(row.income, 0)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{metrics.cogs && (
|
{metrics.cogs && (
|
||||||
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
|
<TableCell className="text-center whitespace-nowrap px-6">
|
||||||
{row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
|
{row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{metrics.profit && (
|
{metrics.profit && (
|
||||||
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
|
<TableCell className="text-center whitespace-nowrap px-6">
|
||||||
{row.isFuture ? "—" : formatCurrency(row.profit, 0)}
|
{row.isFuture ? "—" : formatCurrency(row.profit, 0)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{metrics.margin && (
|
{metrics.margin && (
|
||||||
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
|
<TableCell className="text-center whitespace-nowrap px-6">
|
||||||
{row.isFuture ? "—" : formatPercentage(row.margin, 1)}
|
{row.isFuture ? "—" : formatPercentage(row.margin, 1)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
@@ -1290,133 +1234,48 @@ const FinancialOverview = () => {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex h-full min-h-[200px] items-center justify-center text-sm text-muted-foreground">
|
|
||||||
No detailed data available for this range.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : (
|
</>
|
||||||
<Button variant="outline" size="sm" disabled>
|
|
||||||
Details
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === "rolling" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleViewModeChange("rolling")}
|
|
||||||
>
|
|
||||||
Last 30 Days
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === "custom" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleViewModeChange("custom")}
|
|
||||||
>
|
|
||||||
Custom Period
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{viewMode === "custom" && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Select value={customPeriod.type} onValueChange={handleCustomTypeChange}>
|
|
||||||
<SelectTrigger className="w-[130px]">
|
|
||||||
<SelectValue placeholder="Period" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="month">Month(s)</SelectItem>
|
|
||||||
<SelectItem value="quarter">Quarter(s)</SelectItem>
|
|
||||||
<SelectItem value="year">Year(s)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={String(customPeriod.startYear)} onValueChange={handleStartYearChange}>
|
|
||||||
<SelectTrigger className="w-[110px]">
|
|
||||||
<SelectValue placeholder="Year" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{yearOptions.map((year) => (
|
|
||||||
<SelectItem key={year} value={String(year)}>
|
|
||||||
{year}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{customPeriod.type === "month" && (
|
|
||||||
<Select value={String(customPeriod.startMonth)} onValueChange={handleStartMonthChange}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="Month" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{MONTHS.map((month, index) => (
|
|
||||||
<SelectItem key={month} value={String(index)}>
|
|
||||||
{month}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customPeriod.type === "quarter" && (
|
|
||||||
<Select value={String(customPeriod.startQuarter)} onValueChange={handleStartQuarterChange}>
|
|
||||||
<SelectTrigger className="w-[130px]">
|
|
||||||
<SelectValue placeholder="Quarter" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{QUARTERS.map((quarter, index) => (
|
|
||||||
<SelectItem key={quarter} value={String(index)}>
|
|
||||||
{quarter}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select value={String(customPeriod.count)} onValueChange={handleCountChange}>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="End" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{endPeriodOptions.map((option) => (
|
|
||||||
<SelectItem key={option.count} value={String(option.count)}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground lg:text-right">Range: {selectedRangeLabel}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
{/* Show stats only if not in error state */}
|
||||||
<CardContent className="p-6 pt-0">
|
{!error &&
|
||||||
{loading ? (
|
(loading ? (
|
||||||
<div className="space-y-6">
|
|
||||||
<SkeletonStats />
|
<SkeletonStats />
|
||||||
<SkeletonChart />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<Alert variant="destructive" className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
cards.length > 0 && <FinancialStatGrid cards={cards} />
|
||||||
{cards.length ? <FinancialStatGrid cards={cards} /> : null}
|
))}
|
||||||
<div className="space-y-4 mt-6">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{/* Show metric toggles only if not in error state */}
|
||||||
<span className="text-sm font-medium text-muted-foreground">Chart Metrics</span>
|
{!error && (
|
||||||
|
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
|
<Button
|
||||||
|
key={series.key}
|
||||||
|
variant={metrics[series.key] ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleMetric(series.key)}
|
||||||
|
>
|
||||||
|
{series.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="h-6 hidden sm:block"
|
||||||
|
/>
|
||||||
|
<Separator
|
||||||
|
orientation="horizontal"
|
||||||
|
className="sm:hidden w-20 my-2"
|
||||||
|
/>
|
||||||
|
|
||||||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||||
<SelectTrigger className="w-[140px]">
|
<SelectTrigger className="w-[140px]">
|
||||||
<SelectValue placeholder="Group By" />
|
<SelectValue placeholder="Group By" />
|
||||||
@@ -1430,25 +1289,45 @@ const FinancialOverview = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
)}
|
||||||
{SERIES_DEFINITIONS.map((series) => (
|
</div>
|
||||||
<Button
|
</CardHeader>
|
||||||
key={series.key}
|
<CardContent className="p-6 pt-0">
|
||||||
variant={metrics[series.key] ? "default" : "outline"}
|
{loading ? (
|
||||||
size="sm"
|
<div className="space-y-6">
|
||||||
onClick={() => toggleMetric(series.key)}
|
<SkeletonChart />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Alert
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
{series.label}
|
<AlertCircle className="h-4 w-4" />
|
||||||
</Button>
|
<AlertTitle>Error</AlertTitle>
|
||||||
))}
|
<AlertDescription>
|
||||||
|
Failed to load financial data: {error}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : !hasData ? (
|
||||||
|
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto mb-4" />
|
||||||
|
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||||
|
No financial data available
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Try selecting a different time range
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[360px] rounded-lg border border-border/60 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-4">
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative">
|
||||||
{!hasActiveMetrics ? (
|
{!hasActiveMetrics ? (
|
||||||
<EmptyChartState message="Select at least one metric to visualize." />
|
<EmptyChartState message="Select at least one metric to visualize." />
|
||||||
) : hasData ? (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: -10, bottom: 0 }}>
|
<ComposedChart data={chartData} margin={{ top: 5, right: -30, left: -5, bottom: 5 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="financialIncome" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="financialIncome" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={chartColors.income} stopOpacity={0.3} />
|
<stop offset="5%" stopColor={chartColors.income} stopOpacity={0.3} />
|
||||||
@@ -1463,22 +1342,29 @@ const FinancialOverview = () => {
|
|||||||
<stop offset="95%" stopColor={chartColors.profit} stopOpacity={0.05} />
|
<stop offset="95%" stopColor={chartColors.profit} stopOpacity={0.05} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid
|
||||||
<XAxis dataKey="label" className="text-xs text-muted-foreground" />
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-muted"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
tick={{ fill: "currentColor" }}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
stroke="hsl(var(--muted-foreground))"
|
|
||||||
tickFormatter={(value: number) => formatCurrency(value, 0)}
|
tickFormatter={(value: number) => formatCurrency(value, 0)}
|
||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
|
tick={{ fill: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
{metrics.margin && (
|
{metrics.margin && (
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
stroke={chartColors.margin}
|
|
||||||
tickFormatter={(value: number) => formatPercentage(value, 0)}
|
tickFormatter={(value: number) => formatPercentage(value, 0)}
|
||||||
domain={marginDomain}
|
domain={marginDomain}
|
||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
|
tick={{ fill: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={<FinancialTooltip />} />
|
<Tooltip content={<FinancialTooltip />} />
|
||||||
@@ -1531,11 +1417,8 @@ const FinancialOverview = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
|
||||||
<EmptyChartState message="No financial data available for this range." />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1556,7 +1439,7 @@ function FinancialStatGrid({
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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} />
|
||||||
))}
|
))}
|
||||||
@@ -1578,12 +1461,12 @@ function FinancialStatCard({
|
|||||||
accentClass: string;
|
accentClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white/85 dark:bg-gray-900/60 backdrop-blur-sm border border-border/60">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 p-4 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
<span className="text-sm text-muted-foreground">{title}</span>
|
||||||
{trend?.label && (
|
{trend?.label && (
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium inline-flex items-center gap-1 ${
|
className={`text-sm flex items-center gap-1 ${
|
||||||
trend.direction === "up"
|
trend.direction === "up"
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
: trend.direction === "down"
|
: trend.direction === "down"
|
||||||
@@ -1592,19 +1475,23 @@ function FinancialStatCard({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{trend.direction === "up" ? (
|
{trend.direction === "up" ? (
|
||||||
<ArrowUpRight className="h-3 w-3" />
|
<ArrowUpRight className="w-4 h-4" />
|
||||||
) : trend.direction === "down" ? (
|
) : trend.direction === "down" ? (
|
||||||
<ArrowDownRight className="h-3 w-3" />
|
<ArrowDownRight className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<Minus className="h-3 w-3" />
|
<Minus className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
{trend.label}
|
{trend.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-0">
|
||||||
<div className={`text-2xl font-semibold ${accentClass}`}>{value}</div>
|
<div className={`text-2xl font-bold mb-1 ${accentClass}`}>
|
||||||
{description && <div className="text-xs text-muted-foreground mt-1">{description}</div>}
|
{value}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -1612,16 +1499,16 @@ function FinancialStatCard({
|
|||||||
|
|
||||||
function SkeletonStats() {
|
function SkeletonStats() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<Card key={index} className="bg-white/70 dark:bg-gray-900/50 backdrop-blur-sm border border-border/50">
|
<Card key={index} className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
||||||
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
|
<Skeleton className="h-4 w-8 bg-muted rounded-sm" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0 space-y-2">
|
<CardContent className="p-4 pt-0">
|
||||||
<Skeleton className="h-7 w-24 bg-muted rounded-sm" />
|
<Skeleton className="h-7 w-32 bg-muted rounded-sm mb-1" />
|
||||||
<Skeleton className="h-4 w-32 bg-muted rounded-sm" />
|
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -1631,25 +1518,39 @@ function SkeletonStats() {
|
|||||||
|
|
||||||
function SkeletonChart() {
|
function SkeletonChart() {
|
||||||
return (
|
return (
|
||||||
<div className="h-[360px] rounded-lg border border-border/60 bg-white/70 dark:bg-gray-900/50 backdrop-blur-sm p-6">
|
<div className="h-[400px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
|
||||||
<div className="h-full relative">
|
<div className="h-full flex flex-col">
|
||||||
<div className="absolute inset-0 flex flex-col justify-between">
|
<div className="flex-1 relative">
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
{/* Grid lines */}
|
||||||
<div key={index} className="h-px bg-muted/70" />
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute w-full h-px bg-muted"
|
||||||
|
style={{ top: `${(i + 1) * 16}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Y-axis labels */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-12 flex flex-col justify-between py-4">
|
{/* X-axis labels */}
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
<div className="absolute left-16 right-0 bottom-0 flex justify-between px-4">
|
||||||
<Skeleton key={index} className="h-3 w-10 bg-muted rounded-sm" />
|
{[...Array(7)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-12 right-0 bottom-0 flex justify-between px-4 pb-2">
|
{/* Chart line */}
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
|
||||||
<Skeleton key={index} className="h-3 w-10 bg-muted rounded-sm" />
|
<div
|
||||||
))}
|
className="absolute inset-0 bg-muted/50"
|
||||||
|
style={{
|
||||||
|
clipPath:
|
||||||
|
"polygon(0 50%, 20% 20%, 40% 40%, 60% 30%, 80% 60%, 100% 40%, 100% 100%, 0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 mt-8 ml-16 mr-6">
|
|
||||||
<Skeleton className="h-full w-full bg-muted/60 rounded" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
269
inventory/src/components/dashboard/PeriodSelectionPopover.tsx
Normal file
269
inventory/src/components/dashboard/PeriodSelectionPopover.tsx
Normal 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;
|
||||||
384
inventory/src/utils/naturalLanguagePeriod.ts
Normal file
384
inventory/src/utils/naturalLanguagePeriod.ts
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
export type MonthPeriod = {
|
||||||
|
type: "month";
|
||||||
|
startYear: number;
|
||||||
|
startMonth: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuarterPeriod = {
|
||||||
|
type: "quarter";
|
||||||
|
startYear: number;
|
||||||
|
startQuarter: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type YearPeriod = {
|
||||||
|
type: "year";
|
||||||
|
startYear: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomPeriod = MonthPeriod | QuarterPeriod | YearPeriod;
|
||||||
|
|
||||||
|
export type NaturalLanguagePeriodResult = CustomPeriod | "last30days" | null;
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const QUARTER_NAMES = ["Q1", "Q2", "Q3", "Q4"] as const;
|
||||||
|
|
||||||
|
const MONTH_MAP: Record<string, number> = {
|
||||||
|
jan: 0,
|
||||||
|
january: 0,
|
||||||
|
feb: 1,
|
||||||
|
february: 1,
|
||||||
|
mar: 2,
|
||||||
|
march: 2,
|
||||||
|
apr: 3,
|
||||||
|
april: 3,
|
||||||
|
may: 4,
|
||||||
|
jun: 5,
|
||||||
|
june: 5,
|
||||||
|
jul: 6,
|
||||||
|
july: 6,
|
||||||
|
aug: 7,
|
||||||
|
august: 7,
|
||||||
|
sep: 8,
|
||||||
|
sept: 8,
|
||||||
|
september: 8,
|
||||||
|
oct: 9,
|
||||||
|
october: 9,
|
||||||
|
nov: 10,
|
||||||
|
november: 10,
|
||||||
|
dec: 11,
|
||||||
|
december: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripDelimiters = (value: string) => value.toLowerCase().trim().replace(/[,]/g, "");
|
||||||
|
|
||||||
|
const parseYear = (yearStr: string): number => {
|
||||||
|
const year = parseInt(yearStr, 10);
|
||||||
|
if (yearStr.length === 2) {
|
||||||
|
if (year <= 30) {
|
||||||
|
return 2000 + year;
|
||||||
|
}
|
||||||
|
return 1900 + year;
|
||||||
|
}
|
||||||
|
return year;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMonth = (monthStr: string): number | null => {
|
||||||
|
const month = MONTH_MAP[monthStr.toLowerCase()];
|
||||||
|
return month ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseNaturalLanguagePeriod = (
|
||||||
|
input: string,
|
||||||
|
referenceDate: Date = new Date()
|
||||||
|
): NaturalLanguagePeriodResult => {
|
||||||
|
const lower = stripDelimiters(input);
|
||||||
|
const now = referenceDate;
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth();
|
||||||
|
const currentQuarter = Math.floor(currentMonth / 3);
|
||||||
|
|
||||||
|
if (/^(last\s*)?30\s*days?$/i.test(lower)) {
|
||||||
|
return "last30days";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^this\s+month$/i.test(lower)) {
|
||||||
|
return {
|
||||||
|
type: "month",
|
||||||
|
startYear: currentYear,
|
||||||
|
startMonth: currentMonth,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^last\s+month$/i.test(lower)) {
|
||||||
|
const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1;
|
||||||
|
const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear;
|
||||||
|
return {
|
||||||
|
type: "month",
|
||||||
|
startYear: lastMonthYear,
|
||||||
|
startMonth: lastMonth,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^this\s+quarter$/i.test(lower)) {
|
||||||
|
return {
|
||||||
|
type: "quarter",
|
||||||
|
startYear: currentYear,
|
||||||
|
startQuarter: currentQuarter,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^last\s+quarter$/i.test(lower)) {
|
||||||
|
const lastQuarter = currentQuarter === 0 ? 3 : currentQuarter - 1;
|
||||||
|
const lastQuarterYear = currentQuarter === 0 ? currentYear - 1 : currentYear;
|
||||||
|
return {
|
||||||
|
type: "quarter",
|
||||||
|
startYear: lastQuarterYear,
|
||||||
|
startQuarter: lastQuarter,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^this\s+year$/i.test(lower)) {
|
||||||
|
return {
|
||||||
|
type: "year",
|
||||||
|
startYear: currentYear,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^last\s+year$/i.test(lower)) {
|
||||||
|
return {
|
||||||
|
type: "year",
|
||||||
|
startYear: currentYear - 1,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMatch = lower.match(/^last\s+(\d+)\s*(months?|quarters?|years?|mos?|qtrs?|yrs?)$/i);
|
||||||
|
if (lastMatch) {
|
||||||
|
const count = parseInt(lastMatch[1], 10);
|
||||||
|
const unit = lastMatch[2].toLowerCase();
|
||||||
|
|
||||||
|
let type: "month" | "quarter" | "year";
|
||||||
|
if (/^(months?|mos?)$/i.test(unit)) {
|
||||||
|
type = "month";
|
||||||
|
} else if (/^(quarters?|qtrs?)$/i.test(unit)) {
|
||||||
|
type = "quarter";
|
||||||
|
} else {
|
||||||
|
type = "year";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "month") {
|
||||||
|
const startMonth = currentMonth - count + 1;
|
||||||
|
const startYear = currentYear + Math.floor(startMonth / 12);
|
||||||
|
return {
|
||||||
|
type: "month",
|
||||||
|
startYear,
|
||||||
|
startMonth: ((startMonth % 12) + 12) % 12,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "quarter") {
|
||||||
|
const startQuarter = currentQuarter - count + 1;
|
||||||
|
const startYear = currentYear + Math.floor(startQuarter / 4);
|
||||||
|
return {
|
||||||
|
type: "quarter",
|
||||||
|
startYear,
|
||||||
|
startQuarter: ((startQuarter % 4) + 4) % 4,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "year",
|
||||||
|
startYear: currentYear - count + 1,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const quarterMatch = lower.match(/^q([1-4])(?:\s+|-)(\d{2,4})$/);
|
||||||
|
if (quarterMatch) {
|
||||||
|
return {
|
||||||
|
type: "quarter",
|
||||||
|
startYear: parseYear(quarterMatch[2]),
|
||||||
|
startQuarter: parseInt(quarterMatch[1], 10) - 1,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const quarterRangeMatch = lower.match(/^q([1-4])\s*(?:-|–|—|to|through|until|thru)\s*q([1-4])(?:\s+|-)(\d{2,4})$/);
|
||||||
|
if (quarterRangeMatch) {
|
||||||
|
const startQ = parseInt(quarterRangeMatch[1], 10) - 1;
|
||||||
|
const endQ = parseInt(quarterRangeMatch[2], 10) - 1;
|
||||||
|
const year = parseYear(quarterRangeMatch[3]);
|
||||||
|
|
||||||
|
let count = endQ - startQ + 1;
|
||||||
|
if (count <= 0) {
|
||||||
|
count += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "quarter",
|
||||||
|
startYear: year,
|
||||||
|
startQuarter: startQ,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const quarterRangeAcrossYearsMatch = lower.match(
|
||||||
|
/^q([1-4])(?:\s+|-)(\d{2,4})\s*(?:-|–|—|to|through|until|thru)\s*q([1-4])(?:\s+|-)(\d{2,4})$/
|
||||||
|
);
|
||||||
|
if (quarterRangeAcrossYearsMatch) {
|
||||||
|
const startQ = parseInt(quarterRangeAcrossYearsMatch[1], 10) - 1;
|
||||||
|
const startYear = parseYear(quarterRangeAcrossYearsMatch[2]);
|
||||||
|
const endQ = parseInt(quarterRangeAcrossYearsMatch[3], 10) - 1;
|
||||||
|
const endYear = parseYear(quarterRangeAcrossYearsMatch[4]);
|
||||||
|
|
||||||
|
const totalQuarters = (endYear - startYear) * 4 + (endQ - startQ) + 1;
|
||||||
|
|
||||||
|
if (totalQuarters > 0 && totalQuarters <= 20) {
|
||||||
|
return {
|
||||||
|
type: "quarter",
|
||||||
|
startYear,
|
||||||
|
startQuarter: startQ,
|
||||||
|
count: totalQuarters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearMatch = lower.match(/^(\d{2,4})$/);
|
||||||
|
if (yearMatch) {
|
||||||
|
return {
|
||||||
|
type: "year",
|
||||||
|
startYear: parseYear(yearMatch[1]),
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearRangeMatch = lower.match(/^(\d{2,4})\s*(?:-|–|—|to|through|until|thru)\s*(\d{2,4})$/);
|
||||||
|
if (yearRangeMatch) {
|
||||||
|
const startYear = parseYear(yearRangeMatch[1]);
|
||||||
|
const endYear = parseYear(yearRangeMatch[2]);
|
||||||
|
const count = endYear - startYear + 1;
|
||||||
|
|
||||||
|
if (count > 0 && count <= 10) {
|
||||||
|
return {
|
||||||
|
type: "year",
|
||||||
|
startYear,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleMonthMatch = lower.match(/^([a-z]+)(?:\s+|-)(\d{2,4})$/);
|
||||||
|
if (singleMonthMatch) {
|
||||||
|
const monthIndex = findMonth(singleMonthMatch[1]);
|
||||||
|
if (monthIndex !== null) {
|
||||||
|
return {
|
||||||
|
type: "month",
|
||||||
|
startYear: parseYear(singleMonthMatch[2]),
|
||||||
|
startMonth: monthIndex,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthRangeWithYearMatch = lower.match(
|
||||||
|
/^([a-z]+)\s*(?:-|–|—|to|through|until|thru)\s*([a-z]+)(?:\s+|-)(\d{2,4})$/
|
||||||
|
);
|
||||||
|
if (monthRangeWithYearMatch) {
|
||||||
|
const startMonthIndex = findMonth(monthRangeWithYearMatch[1]);
|
||||||
|
const endMonthIndex = findMonth(monthRangeWithYearMatch[2]);
|
||||||
|
const year = parseYear(monthRangeWithYearMatch[3]);
|
||||||
|
|
||||||
|
if (startMonthIndex !== null && endMonthIndex !== null) {
|
||||||
|
let count = endMonthIndex - startMonthIndex + 1;
|
||||||
|
if (count <= 0) {
|
||||||
|
count += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "month",
|
||||||
|
startYear: year,
|
||||||
|
startMonth: startMonthIndex,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthRangeAcrossYearsMatch = lower.match(
|
||||||
|
/^([a-z]+)(?:\s+|-)(\d{2,4})\s*(?:-|–|—|to|through|until|thru)\s*([a-z]+)(?:\s+|-)(\d{2,4})$/
|
||||||
|
);
|
||||||
|
if (monthRangeAcrossYearsMatch) {
|
||||||
|
const startMonthIndex = findMonth(monthRangeAcrossYearsMatch[1]);
|
||||||
|
const startYear = parseYear(monthRangeAcrossYearsMatch[2]);
|
||||||
|
const endMonthIndex = findMonth(monthRangeAcrossYearsMatch[3]);
|
||||||
|
const endYear = parseYear(monthRangeAcrossYearsMatch[4]);
|
||||||
|
|
||||||
|
if (startMonthIndex !== null && endMonthIndex !== null) {
|
||||||
|
const totalMonths = (endYear - startYear) * 12 + (endMonthIndex - startMonthIndex) + 1;
|
||||||
|
|
||||||
|
if (totalMonths > 0 && totalMonths <= 60) {
|
||||||
|
return {
|
||||||
|
type: "month",
|
||||||
|
startYear,
|
||||||
|
startMonth: startMonthIndex,
|
||||||
|
count: totalMonths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateNaturalLanguagePreview = (
|
||||||
|
parsed: NaturalLanguagePeriodResult
|
||||||
|
): string | null => {
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed === "last30days") {
|
||||||
|
return "Last 30 days";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "year") {
|
||||||
|
if (parsed.count === 1) {
|
||||||
|
return `${parsed.startYear}`;
|
||||||
|
}
|
||||||
|
const endYear = parsed.startYear + parsed.count - 1;
|
||||||
|
return `${parsed.startYear} to ${endYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "quarter") {
|
||||||
|
if (parsed.count === 1) {
|
||||||
|
return `${QUARTER_NAMES[parsed.startQuarter]} ${parsed.startYear}`;
|
||||||
|
}
|
||||||
|
const endQuarter = (parsed.startQuarter + parsed.count - 1) % 4;
|
||||||
|
const endYear = parsed.startYear + Math.floor((parsed.startQuarter + parsed.count - 1) / 4);
|
||||||
|
|
||||||
|
if (parsed.startYear === endYear) {
|
||||||
|
return `${QUARTER_NAMES[parsed.startQuarter]} to ${QUARTER_NAMES[endQuarter]} ${parsed.startYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${QUARTER_NAMES[parsed.startQuarter]} ${parsed.startYear} to ${QUARTER_NAMES[endQuarter]} ${endYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "month") {
|
||||||
|
if (parsed.count === 1) {
|
||||||
|
return `${MONTH_NAMES[parsed.startMonth]} ${parsed.startYear}`;
|
||||||
|
}
|
||||||
|
const endMonth = (parsed.startMonth + parsed.count - 1) % 12;
|
||||||
|
const endYear = parsed.startYear + Math.floor((parsed.startMonth + parsed.count - 1) / 12);
|
||||||
|
|
||||||
|
if (parsed.startYear === endYear) {
|
||||||
|
return `${MONTH_NAMES[parsed.startMonth]} to ${MONTH_NAMES[endMonth]} ${parsed.startYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${MONTH_NAMES[parsed.startMonth]} ${parsed.startYear} to ${MONTH_NAMES[endMonth]} ${endYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user