Compare commits
2 commits
cab4cc174a
...
a50be5caf6
| Author | SHA1 | Date | |
|---|---|---|---|
| a50be5caf6 | |||
|
|
91430e994a |
21 changed files with 333 additions and 1228 deletions
|
|
@ -10,6 +10,10 @@ import CategoriesPage from "./pages/CategoriesPage";
|
|||
import AdjustmentsPage from "./pages/AdjustmentsPage";
|
||||
import BudgetPage from "./pages/BudgetPage";
|
||||
import ReportsPage from "./pages/ReportsPage";
|
||||
import ReportsHighlightsPage from "./pages/ReportsHighlightsPage";
|
||||
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
|
||||
import ReportsComparePage from "./pages/ReportsComparePage";
|
||||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import DocsPage from "./pages/DocsPage";
|
||||
import ChangelogPage from "./pages/ChangelogPage";
|
||||
|
|
@ -101,6 +105,10 @@ export default function App() {
|
|||
<Route path="/adjustments" element={<AdjustmentsPage />} />
|
||||
<Route path="/budget" element={<BudgetPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
|
||||
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
|
||||
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Table, BarChart3, Columns, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { PivotConfig, PivotResult } from "../../shared/types";
|
||||
import DynamicReportPanel from "./DynamicReportPanel";
|
||||
import DynamicReportTable from "./DynamicReportTable";
|
||||
import DynamicReportChart from "./DynamicReportChart";
|
||||
|
||||
type ViewMode = "table" | "chart" | "both";
|
||||
|
||||
interface DynamicReportProps {
|
||||
config: PivotConfig;
|
||||
result: PivotResult;
|
||||
onConfigChange: (config: PivotConfig) => void;
|
||||
}
|
||||
|
||||
export default function DynamicReport({ config, result, onConfigChange }: DynamicReportProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const toggleFullscreen = useCallback(() => setFullscreen((prev) => !prev), []);
|
||||
|
||||
// Escape key exits fullscreen
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setFullscreen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [fullscreen]);
|
||||
|
||||
const hasConfig = (config.rows.length > 0 || config.columns.length > 0) && config.values.length > 0;
|
||||
|
||||
const viewButtons: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ mode: "table", icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
|
||||
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
|
||||
{ mode: "both", icon: <Columns size={14} />, label: t("reports.pivot.viewBoth") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
fullscreen
|
||||
? "fixed inset-0 z-50 bg-[var(--background)] overflow-auto p-6"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4 items-start">
|
||||
{/* Content area */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1">
|
||||
{hasConfig && viewButtons.map(({ mode, icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
mode === viewMode
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
title={fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
{fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{!hasConfig && (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-12 text-center text-[var(--muted-foreground)]">
|
||||
{t("reports.pivot.noConfig")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{hasConfig && (viewMode === "table" || viewMode === "both") && (
|
||||
<DynamicReportTable config={config} result={result} />
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
{hasConfig && (viewMode === "chart" || viewMode === "both") && (
|
||||
<DynamicReportChart config={config} result={result} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<DynamicReportPanel
|
||||
config={config}
|
||||
onChange={onConfigChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import type { PivotConfig, PivotResult } from "../../shared/types";
|
||||
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
// Generate distinct colors for series
|
||||
const SERIES_COLORS = [
|
||||
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
|
||||
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
|
||||
"#d946ef", "#0ea5e9", "#eab308", "#22c55e", "#e11d48",
|
||||
];
|
||||
|
||||
interface DynamicReportChartProps {
|
||||
config: PivotConfig;
|
||||
result: PivotResult;
|
||||
}
|
||||
|
||||
export default function DynamicReportChart({ config, result }: DynamicReportChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { chartData, seriesKeys, seriesColors } = useMemo(() => {
|
||||
if (result.rows.length === 0) {
|
||||
return { chartData: [], seriesKeys: [], seriesColors: {} };
|
||||
}
|
||||
|
||||
const colDims = config.columns;
|
||||
const rowDim = config.rows[0];
|
||||
const measure = config.values[0] || "periodic";
|
||||
|
||||
// X-axis = composite column key (or first row dimension if no columns)
|
||||
const hasColDims = colDims.length > 0;
|
||||
if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
|
||||
|
||||
// Build composite column key per row
|
||||
const getColKey = (r: typeof result.rows[0]) =>
|
||||
colDims.map((d) => r.keys[d] || "").join(" — ");
|
||||
|
||||
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
|
||||
const seriesDim = hasColDims ? rowDim : undefined;
|
||||
|
||||
// Collect unique x and series values
|
||||
const xValues = hasColDims
|
||||
? [...new Set(result.rows.map(getColKey))].sort()
|
||||
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].sort();
|
||||
const seriesVals = seriesDim
|
||||
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
|
||||
: [measure];
|
||||
|
||||
// Build chart data: one entry per x value
|
||||
const data = xValues.map((xVal) => {
|
||||
const entry: Record<string, string | number> = { name: xVal };
|
||||
if (seriesDim) {
|
||||
for (const sv of seriesVals) {
|
||||
const matchingRows = result.rows.filter(
|
||||
(r) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
|
||||
);
|
||||
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
|
||||
}
|
||||
} else {
|
||||
const matchingRows = result.rows.filter((r) =>
|
||||
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
|
||||
);
|
||||
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
seriesVals.forEach((sv, i) => {
|
||||
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
|
||||
});
|
||||
|
||||
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
|
||||
}, [config, result]);
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryEntries = seriesKeys.map((key, index) => ({
|
||||
color: seriesColors[key],
|
||||
index,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => cadFormatter(v)}
|
||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||
stroke="var(--border)"
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
labelStyle={{ color: "var(--foreground)" }}
|
||||
itemStyle={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<Legend />
|
||||
{seriesKeys.map((key, index) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
stackId="stack"
|
||||
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
|
||||
import { getDynamicFilterValues } from "../../services/reportService";
|
||||
|
||||
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
|
||||
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
|
||||
|
||||
interface DynamicReportPanelProps {
|
||||
config: PivotConfig;
|
||||
onChange: (config: PivotConfig) => void;
|
||||
}
|
||||
|
||||
export default function DynamicReportPanel({ config, onChange }: DynamicReportPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [menuTarget, setMenuTarget] = useState<{ id: string; type: "field" | "measure"; x: number; y: number } | null>(null);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
|
||||
const inRows = new Set(config.rows);
|
||||
const inColumns = new Set(config.columns);
|
||||
const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]);
|
||||
const assignedFields = new Set(
|
||||
ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f))
|
||||
);
|
||||
const assignedMeasures = new Set(config.values);
|
||||
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
|
||||
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
|
||||
|
||||
// Load filter values when a field is added to filters
|
||||
const filterFieldIds = Object.keys(config.filters) as PivotFieldId[];
|
||||
useEffect(() => {
|
||||
for (const fieldId of filterFieldIds) {
|
||||
if (!filterValues[fieldId]) {
|
||||
getDynamicFilterValues(fieldId as PivotFieldId).then((vals) => {
|
||||
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [filterFieldIds.join(",")]);
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
if (!menuTarget) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuTarget(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [menuTarget]);
|
||||
|
||||
const handleFieldClick = (id: string, type: "field" | "measure", e: React.MouseEvent) => {
|
||||
setMenuTarget({ id, type, x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const assignTo = useCallback((zone: PivotZone) => {
|
||||
if (!menuTarget) return;
|
||||
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
|
||||
|
||||
if (menuTarget.type === "measure") {
|
||||
if (zone === "values") {
|
||||
next.values = [...next.values, menuTarget.id as PivotMeasureId];
|
||||
}
|
||||
} else {
|
||||
const fieldId = menuTarget.id as PivotFieldId;
|
||||
if (zone === "rows") next.rows = [...next.rows, fieldId];
|
||||
else if (zone === "columns") next.columns = [...next.columns, fieldId];
|
||||
else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: { include: [], exclude: [] } };
|
||||
}
|
||||
|
||||
setMenuTarget(null);
|
||||
onChange(next);
|
||||
}, [menuTarget, config, onChange]);
|
||||
|
||||
const removeFrom = (zone: PivotZone, id: string) => {
|
||||
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
|
||||
if (zone === "rows") next.rows = next.rows.filter((f) => f !== id);
|
||||
else if (zone === "columns") next.columns = next.columns.filter((f) => f !== id);
|
||||
else if (zone === "filters") {
|
||||
const { [id]: _, ...rest } = next.filters;
|
||||
next.filters = rest;
|
||||
} else if (zone === "values") next.values = next.values.filter((m) => m !== id);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const toggleFilterInclude = (fieldId: string, value: string) => {
|
||||
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||
const isIncluded = entry.include.includes(value);
|
||||
const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value];
|
||||
// Remove from exclude if adding to include
|
||||
const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value);
|
||||
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
|
||||
};
|
||||
|
||||
const toggleFilterExclude = (fieldId: string, value: string) => {
|
||||
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||
const isExcluded = entry.exclude.includes(value);
|
||||
const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value];
|
||||
// Remove from include if adding to exclude
|
||||
const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value);
|
||||
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
|
||||
};
|
||||
|
||||
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`);
|
||||
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
|
||||
|
||||
// Context menu only shows zones where the field is NOT already assigned
|
||||
const getAvailableZones = (fieldId: string): PivotZone[] => {
|
||||
const zones: PivotZone[] = [];
|
||||
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
|
||||
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
|
||||
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
|
||||
return zones;
|
||||
};
|
||||
|
||||
const zoneLabels: Record<PivotZone, string> = {
|
||||
rows: t("reports.pivot.rows"),
|
||||
columns: t("reports.pivot.columns"),
|
||||
filters: t("reports.pivot.filters"),
|
||||
values: t("reports.pivot.values"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
|
||||
{/* Available Fields */}
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--muted-foreground)] mb-2">{t("reports.pivot.availableFields")}</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{availableFields.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={(e) => handleFieldClick(f, "field", e)}
|
||||
className="px-2.5 py-1 rounded-lg bg-[var(--muted)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors text-xs"
|
||||
>
|
||||
{fieldLabel(f)}
|
||||
</button>
|
||||
))}
|
||||
{availableMeasures.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={(e) => handleFieldClick(m, "measure", e)}
|
||||
className="px-2.5 py-1 rounded-lg bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20 transition-colors text-xs"
|
||||
>
|
||||
{measureLabel(m)}
|
||||
</button>
|
||||
))}
|
||||
{availableFields.length === 0 && availableMeasures.length === 0 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<ZoneSection
|
||||
label={t("reports.pivot.rows")}
|
||||
items={config.rows}
|
||||
getLabel={fieldLabel}
|
||||
onRemove={(id) => removeFrom("rows", id)}
|
||||
/>
|
||||
|
||||
{/* Columns */}
|
||||
<ZoneSection
|
||||
label={t("reports.pivot.columns")}
|
||||
items={config.columns}
|
||||
getLabel={fieldLabel}
|
||||
onRemove={(id) => removeFrom("columns", id)}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{t("reports.pivot.filters")}</h3>
|
||||
{filterFieldIds.length === 0 ? (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filterFieldIds.map((fieldId) => {
|
||||
const entry = config.filters[fieldId] || { include: [], exclude: [] };
|
||||
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
|
||||
return (
|
||||
<div key={fieldId}>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
|
||||
<button onClick={() => removeFrom("filters", fieldId)} className="text-[var(--muted-foreground)] hover:text-[var(--negative)]">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(filterValues[fieldId] || []).map((val) => {
|
||||
const isIncluded = entry.include.includes(val);
|
||||
const isExcluded = entry.exclude.includes(val);
|
||||
return (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => toggleFilterInclude(fieldId, val)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
toggleFilterExclude(fieldId, val);
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||
isIncluded
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: isExcluded
|
||||
? "bg-[var(--negative)] text-white line-through"
|
||||
: hasActive
|
||||
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
|
||||
: "bg-[var(--muted)] text-[var(--foreground)]"
|
||||
}`}
|
||||
title={t("reports.pivot.rightClickExclude")}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
<ZoneSection
|
||||
label={t("reports.pivot.values")}
|
||||
items={config.values}
|
||||
getLabel={measureLabel}
|
||||
onRemove={(id) => removeFrom("values", id)}
|
||||
accent
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
{menuTarget && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1 min-w-[140px]"
|
||||
style={{ left: menuTarget.x, top: menuTarget.y }}
|
||||
>
|
||||
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
|
||||
{menuTarget.type === "measure" ? (
|
||||
<button
|
||||
onClick={() => assignTo("values")}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{zoneLabels.values}
|
||||
</button>
|
||||
) : (
|
||||
getAvailableZones(menuTarget.id).map((zone) => (
|
||||
<button
|
||||
key={zone}
|
||||
onClick={() => assignTo(zone)}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{zoneLabels[zone]}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneSection({
|
||||
label,
|
||||
items,
|
||||
getLabel,
|
||||
onRemove,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
items: string[];
|
||||
getLabel: (id: string) => string;
|
||||
onRemove: (id: string) => void;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
|
||||
{items.length === 0 ? (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
|
||||
accent
|
||||
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
: "bg-[var(--muted)] text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{getLabel(id)}
|
||||
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
import { Fragment, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const STORAGE_KEY = "pivot-subtotals-position";
|
||||
|
||||
interface DynamicReportTableProps {
|
||||
config: PivotConfig;
|
||||
result: PivotResult;
|
||||
}
|
||||
|
||||
/** A pivoted row: one per unique combination of row dimensions */
|
||||
interface PivotedRow {
|
||||
rowKeys: Record<string, string>; // row-dimension values
|
||||
cells: Record<string, Record<string, number>>; // colValue → measure → value
|
||||
}
|
||||
|
||||
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
|
||||
function pivotRows(
|
||||
rows: PivotResultRow[],
|
||||
rowDims: string[],
|
||||
colDims: string[],
|
||||
measures: string[],
|
||||
): PivotedRow[] {
|
||||
const map = new Map<string, PivotedRow>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
|
||||
let pivoted = map.get(rowKey);
|
||||
if (!pivoted) {
|
||||
const rowKeys: Record<string, string> = {};
|
||||
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
|
||||
pivoted = { rowKeys, cells: {} };
|
||||
map.set(rowKey, pivoted);
|
||||
}
|
||||
|
||||
const colKey = colDims.length > 0
|
||||
? colDims.map((d) => row.keys[d] || "").join("\0")
|
||||
: "__all__";
|
||||
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
|
||||
for (const m of measures) {
|
||||
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
interface GroupNode {
|
||||
key: string;
|
||||
label: string;
|
||||
pivotedRows: PivotedRow[];
|
||||
children: GroupNode[];
|
||||
}
|
||||
|
||||
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
|
||||
if (depth >= rowDims.length) return [];
|
||||
const dim = rowDims[depth];
|
||||
const map = new Map<string, PivotedRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.rowKeys[dim] || "";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(row);
|
||||
}
|
||||
const groups: GroupNode[] = [];
|
||||
for (const [key, groupRows] of map) {
|
||||
groups.push({
|
||||
key,
|
||||
label: key,
|
||||
pivotedRows: groupRows,
|
||||
children: buildGroups(groupRows, rowDims, depth + 1),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function computeSubtotals(
|
||||
rows: PivotedRow[],
|
||||
measures: string[],
|
||||
colValues: string[],
|
||||
): Record<string, Record<string, number>> {
|
||||
const result: Record<string, Record<string, number>> = {};
|
||||
for (const colVal of colValues) {
|
||||
result[colVal] = {};
|
||||
for (const m of measures) {
|
||||
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === null ? true : stored === "top";
|
||||
});
|
||||
|
||||
const toggleSubtotals = () => {
|
||||
setSubtotalsOnTop((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const rowDims = config.rows;
|
||||
const colDims = config.columns;
|
||||
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
|
||||
const measures = config.values;
|
||||
|
||||
// Display label for a composite column key (joined with \0)
|
||||
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
|
||||
|
||||
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
|
||||
const pivotedRows = useMemo(
|
||||
() => pivotRows(result.rows, rowDims, colDims, measures),
|
||||
[result.rows, rowDims, colDims, measures],
|
||||
);
|
||||
|
||||
if (pivotedRows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("reports.pivot.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
|
||||
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
|
||||
|
||||
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
|
||||
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
{rowDims.length > 1 && (
|
||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
{rowDims.map((dim) => (
|
||||
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{fieldLabel(dim)}
|
||||
</th>
|
||||
))}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)} — ${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
|
||||
</th>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowDims.length === 0 ? (
|
||||
<tr className="border-b border-[var(--border)]/50">
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<GroupRows
|
||||
key={group.key}
|
||||
group={group}
|
||||
colValues={colValues}
|
||||
measures={measures}
|
||||
rowDims={rowDims}
|
||||
depth={0}
|
||||
subtotalsOnTop={subtotalsOnTop}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{/* Grand total */}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||
<td colSpan={rowDims.length || 1} className="px-3 py-3">
|
||||
{t("reports.pivot.total")}
|
||||
</td>
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
group,
|
||||
colValues,
|
||||
measures,
|
||||
rowDims,
|
||||
depth,
|
||||
subtotalsOnTop,
|
||||
}: {
|
||||
group: GroupNode;
|
||||
colValues: string[];
|
||||
measures: string[];
|
||||
rowDims: string[];
|
||||
depth: number;
|
||||
subtotalsOnTop: boolean;
|
||||
}) {
|
||||
const isLeafLevel = depth === rowDims.length - 1;
|
||||
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
|
||||
|
||||
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
|
||||
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
|
||||
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
|
||||
{group.label}
|
||||
</td>
|
||||
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(subtotals[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
) : null;
|
||||
|
||||
if (isLeafLevel) {
|
||||
// Render one table row per pivoted row (already deduplicated by row keys)
|
||||
return (
|
||||
<>
|
||||
{group.pivotedRows.map((pRow, i) => (
|
||||
<tr key={i} className="border-b border-[var(--border)]/50">
|
||||
{rowDims.map((dim, di) => (
|
||||
<td
|
||||
key={dim}
|
||||
className="px-3 py-1.5"
|
||||
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
|
||||
>
|
||||
{pRow.rowKeys[dim] || ""}
|
||||
</td>
|
||||
))}
|
||||
{colValues.map((colVal) =>
|
||||
measures.map((m) => (
|
||||
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
|
||||
</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const childContent = group.children.map((child) => (
|
||||
<GroupRows
|
||||
key={child.key}
|
||||
group={child}
|
||||
colValues={colValues}
|
||||
measures={measures}
|
||||
rowDims={rowDims}
|
||||
depth={depth + 1}
|
||||
subtotalsOnTop={subtotalsOnTop}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{subtotalsOnTop && subtotalRow}
|
||||
{childContent}
|
||||
{!subtotalsOnTop && subtotalRow}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
|
|||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.pivot.month")}
|
||||
{t("reports.month")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("dashboard.income")}
|
||||
|
|
|
|||
39
src/components/reports/Sparkline.tsx
Normal file
39
src/components/reports/Sparkline.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
color?: string;
|
||||
width?: number | `${number}%`;
|
||||
height?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
color = "var(--primary)",
|
||||
width = "100%",
|
||||
height = 32,
|
||||
strokeWidth = 1.5,
|
||||
}: SparklineProps) {
|
||||
if (data.length === 0) {
|
||||
return <div style={{ width, height }} />;
|
||||
}
|
||||
|
||||
const chartData = data.map((value, index) => ({ index, value }));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width={width} height={height}>
|
||||
<LineChart data={chartData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
46
src/components/reports/ViewModeToggle.test.ts
Normal file
46
src/components/reports/ViewModeToggle.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { readViewMode } from "./ViewModeToggle";
|
||||
|
||||
describe("readViewMode", () => {
|
||||
const store = new Map<string, string>();
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
clear: vi.fn(() => store.clear()),
|
||||
key: vi.fn(),
|
||||
length: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
vi.stubGlobal("localStorage", mockLocalStorage);
|
||||
});
|
||||
|
||||
it("returns fallback when key is missing", () => {
|
||||
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
|
||||
});
|
||||
|
||||
it("returns 'chart' when stored value is 'chart'", () => {
|
||||
store.set("reports-viewmode-highlights", "chart");
|
||||
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
|
||||
});
|
||||
|
||||
it("returns 'table' when stored value is 'table'", () => {
|
||||
store.set("reports-viewmode-highlights", "table");
|
||||
expect(readViewMode("reports-viewmode-highlights")).toBe("table");
|
||||
});
|
||||
|
||||
it("ignores invalid stored values and returns fallback", () => {
|
||||
store.set("reports-viewmode-highlights", "bogus");
|
||||
expect(readViewMode("reports-viewmode-highlights", "table")).toBe("table");
|
||||
});
|
||||
|
||||
it("respects custom fallback when provided", () => {
|
||||
expect(readViewMode("reports-viewmode-compare", "table")).toBe("table");
|
||||
});
|
||||
});
|
||||
52
src/components/reports/ViewModeToggle.tsx
Normal file
52
src/components/reports/ViewModeToggle.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, Table } from "lucide-react";
|
||||
|
||||
export type ViewMode = "chart" | "table";
|
||||
|
||||
export interface ViewModeToggleProps {
|
||||
value: ViewMode;
|
||||
onChange: (mode: ViewMode) => void;
|
||||
/** localStorage key used to persist the preference per section. */
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function readViewMode(storageKey: string, fallback: ViewMode = "chart"): ViewMode {
|
||||
if (typeof localStorage === "undefined") return fallback;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
return saved === "chart" || saved === "table" ? saved : fallback;
|
||||
}
|
||||
|
||||
export default function ViewModeToggle({ value, onChange, storageKey }: ViewModeToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey) localStorage.setItem(storageKey, value);
|
||||
}, [value, storageKey]);
|
||||
|
||||
const options: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
|
||||
{ mode: "table", icon: <Table size={14} />, label: t("reports.viewMode.table") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="inline-flex gap-1" role="group" aria-label={t("reports.viewMode.chart")}>
|
||||
{options.map(({ mode, icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => onChange(mode)}
|
||||
aria-pressed={value === mode}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
value === mode
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EyeOff, List } from "lucide-react";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
|
||||
export interface ChartContextMenuProps {
|
||||
x: number;
|
||||
|
|
@ -20,60 +20,25 @@ export default function ChartContextMenu({
|
|||
onClose,
|
||||
}: ChartContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
useEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
menuRef.current.style.left = `${x - rect.width}px`;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
menuRef.current.style.top = `${y - rect.height}px`;
|
||||
}
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
|
||||
{categoryName}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { onViewDetails(); onClose(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("charts.viewTransactions")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onHide(); onClose(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
{t("charts.hideCategory")}
|
||||
</button>
|
||||
</div>
|
||||
<ContextMenu
|
||||
x={x}
|
||||
y={y}
|
||||
header={categoryName}
|
||||
onClose={onClose}
|
||||
items={[
|
||||
{
|
||||
icon: <List size={14} />,
|
||||
label: t("charts.viewTransactions"),
|
||||
onClick: onViewDetails,
|
||||
},
|
||||
{
|
||||
icon: <EyeOff size={14} />,
|
||||
label: t("charts.hideCategory"),
|
||||
onClick: onHide,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
src/components/shared/ContextMenu.tsx
Normal file
77
src/components/shared/ContextMenu.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
header?: ReactNode;
|
||||
items: ContextMenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, header, items, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
menuRef.current.style.left = `${x - rect.width}px`;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
menuRef.current.style.top = `${y - rect.height}px`;
|
||||
}
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{header && (
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,10 +6,8 @@ import type {
|
|||
CategoryBreakdownItem,
|
||||
CategoryOverTimeData,
|
||||
BudgetVsActualRow,
|
||||
PivotConfig,
|
||||
PivotResult,
|
||||
} from "../shared/types";
|
||||
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||
import { getExpensesByCategory } from "../services/dashboardService";
|
||||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
|
|
@ -29,8 +27,6 @@ interface ReportsState {
|
|||
budgetYear: number;
|
||||
budgetMonth: number;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
pivotConfig: PivotConfig;
|
||||
pivotResult: PivotResult;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -45,8 +41,6 @@ type ReportsAction =
|
|||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
|
||||
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
||||
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
||||
| { type: "SET_SOURCE_ID"; payload: number | null }
|
||||
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
|
||||
|
|
@ -68,8 +62,6 @@ const initialState: ReportsState = {
|
|||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||
budgetVsActual: [],
|
||||
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
||||
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -94,10 +86,6 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
|||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||
case "SET_BUDGET_VS_ACTUAL":
|
||||
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
||||
case "SET_PIVOT_CONFIG":
|
||||
return { ...state, pivotConfig: action.payload };
|
||||
case "SET_PIVOT_RESULT":
|
||||
return { ...state, pivotResult: action.payload, isLoading: false };
|
||||
case "SET_CUSTOM_DATES":
|
||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||
case "SET_SOURCE_ID":
|
||||
|
|
@ -120,7 +108,6 @@ export function useReports() {
|
|||
budgetMonth: number,
|
||||
customFrom?: string,
|
||||
customTo?: string,
|
||||
pivotCfg?: PivotConfig,
|
||||
srcId?: number | null,
|
||||
catType?: CategoryTypeFilter,
|
||||
) => {
|
||||
|
|
@ -157,16 +144,6 @@ export function useReports() {
|
|||
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
||||
break;
|
||||
}
|
||||
case "dynamic": {
|
||||
if (!pivotCfg || (pivotCfg.rows.length === 0 && pivotCfg.columns.length === 0) || pivotCfg.values.length === 0) {
|
||||
dispatch({ type: "SET_PIVOT_RESULT", payload: { rows: [], columnValues: [], dimensionLabels: {} } });
|
||||
break;
|
||||
}
|
||||
const data = await getDynamicReportData(pivotCfg);
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_PIVOT_RESULT", payload: data });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
|
|
@ -178,8 +155,8 @@ export function useReports() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType);
|
||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, state.categoryType, fetchData]);
|
||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType);
|
||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType, fetchData]);
|
||||
|
||||
const setTab = useCallback((tab: ReportTab) => {
|
||||
dispatch({ type: "SET_TAB", payload: tab });
|
||||
|
|
@ -197,10 +174,6 @@ export function useReports() {
|
|||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||
}, []);
|
||||
|
||||
const setPivotConfig = useCallback((config: PivotConfig) => {
|
||||
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
|
||||
}, []);
|
||||
|
||||
const setSourceId = useCallback((id: number | null) => {
|
||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||
}, []);
|
||||
|
|
@ -209,5 +182,5 @@ export function useReports() {
|
|||
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
|
||||
}, []);
|
||||
|
||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType };
|
||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setSourceId, setCategoryType };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,33 +381,23 @@
|
|||
"noData": "No budget or transaction data for this period.",
|
||||
"titlePrefix": "Budget vs Actual for"
|
||||
},
|
||||
"dynamic": "Dynamic Report",
|
||||
"export": "Export",
|
||||
"pivot": {
|
||||
"availableFields": "Available Fields",
|
||||
"rows": "Rows",
|
||||
"columns": "Columns",
|
||||
"filters": "Filters",
|
||||
"values": "Values",
|
||||
"addTo": "Add to...",
|
||||
"year": "Year",
|
||||
"month": "Month",
|
||||
"categoryType": "Type",
|
||||
"level1": "Category (Level 1)",
|
||||
"level2": "Category (Level 2)",
|
||||
"level3": "Category (Level 3)",
|
||||
"periodic": "Periodic Amount",
|
||||
"ytd": "Year-to-Date (YTD)",
|
||||
"subtotal": "Subtotal",
|
||||
"total": "Total",
|
||||
"viewTable": "Table",
|
||||
"viewChart": "Chart",
|
||||
"viewBoth": "Both",
|
||||
"noConfig": "Add fields to generate the report",
|
||||
"noData": "No data for this configuration",
|
||||
"fullscreen": "Full screen",
|
||||
"exitFullscreen": "Exit full screen",
|
||||
"rightClickExclude": "Right-click to exclude"
|
||||
"viewMode": {
|
||||
"chart": "Chart",
|
||||
"table": "Table"
|
||||
},
|
||||
"hub": {
|
||||
"title": "Reports",
|
||||
"explore": "Explore",
|
||||
"highlights": "Highlights",
|
||||
"trends": "Trends",
|
||||
"compare": "Compare",
|
||||
"categoryZoom": "Category Analysis"
|
||||
},
|
||||
"empty": {
|
||||
"noData": "No data for this period",
|
||||
"importCta": "Import a statement"
|
||||
},
|
||||
"help": {
|
||||
"title": "How to use Reports",
|
||||
|
|
@ -415,8 +405,7 @@
|
|||
"Switch between Trends, By Category, and Over Time views using the tabs",
|
||||
"Use the period selector to adjust the time range for all charts",
|
||||
"Monthly Trends shows your income and expenses over time",
|
||||
"Category Over Time tracks how spending in each category evolves",
|
||||
"Dynamic Report lets you build custom pivot tables by assigning dimensions to rows, columns, and filters"
|
||||
"Category Over Time tracks how spending in each category evolves"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -745,7 +734,6 @@
|
|||
"Expenses by Category: spending breakdown (pie chart)",
|
||||
"Category Over Time: track how each category evolves (line chart)",
|
||||
"Budget vs Actual: monthly and year-to-date comparison table",
|
||||
"Dynamic Report: customizable pivot table",
|
||||
"SVG patterns (lines, dots, crosshatch) to distinguish categories",
|
||||
"Context menu (right-click) to hide a category or view its transactions",
|
||||
"Transaction detail by category with sortable columns (date, description, amount)",
|
||||
|
|
@ -848,7 +836,8 @@
|
|||
"total": "Total",
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"underConstruction": "Under construction"
|
||||
},
|
||||
"license": {
|
||||
"title": "License",
|
||||
|
|
|
|||
|
|
@ -381,33 +381,23 @@
|
|||
"noData": "Aucune donnée de budget ou de transaction pour cette période.",
|
||||
"titlePrefix": "Budget vs Réel pour le mois de"
|
||||
},
|
||||
"dynamic": "Rapport dynamique",
|
||||
"export": "Exporter",
|
||||
"pivot": {
|
||||
"availableFields": "Champs disponibles",
|
||||
"rows": "Lignes",
|
||||
"columns": "Colonnes",
|
||||
"filters": "Filtres",
|
||||
"values": "Valeurs",
|
||||
"addTo": "Ajouter à...",
|
||||
"year": "Année",
|
||||
"month": "Mois",
|
||||
"categoryType": "Type",
|
||||
"level1": "Catégorie (Niveau 1)",
|
||||
"level2": "Catégorie (Niveau 2)",
|
||||
"level3": "Catégorie (Niveau 3)",
|
||||
"periodic": "Montant périodique",
|
||||
"ytd": "Cumul annuel (YTD)",
|
||||
"subtotal": "Sous-total",
|
||||
"total": "Total",
|
||||
"viewTable": "Tableau",
|
||||
"viewChart": "Graphique",
|
||||
"viewBoth": "Les deux",
|
||||
"noConfig": "Ajoutez des champs pour générer le rapport",
|
||||
"noData": "Aucune donnée pour cette configuration",
|
||||
"fullscreen": "Plein écran",
|
||||
"exitFullscreen": "Quitter plein écran",
|
||||
"rightClickExclude": "Clic-droit pour exclure"
|
||||
"viewMode": {
|
||||
"chart": "Graphique",
|
||||
"table": "Tableau"
|
||||
},
|
||||
"hub": {
|
||||
"title": "Rapports",
|
||||
"explore": "Explorer",
|
||||
"highlights": "Faits saillants",
|
||||
"trends": "Tendances",
|
||||
"compare": "Comparables",
|
||||
"categoryZoom": "Analyse par catégorie"
|
||||
},
|
||||
"empty": {
|
||||
"noData": "Aucune donnée pour cette période",
|
||||
"importCta": "Importer un relevé"
|
||||
},
|
||||
"help": {
|
||||
"title": "Comment utiliser les Rapports",
|
||||
|
|
@ -415,8 +405,7 @@
|
|||
"Basculez entre les vues Tendances, Par catégorie et Dans le temps via les onglets",
|
||||
"Utilisez le sélecteur de période pour ajuster la plage de dates de tous les graphiques",
|
||||
"Les tendances mensuelles montrent vos revenus et dépenses au fil du temps",
|
||||
"Catégories dans le temps suit l'évolution des dépenses par catégorie",
|
||||
"Le Rapport dynamique permet de créer des tableaux croisés personnalisés en assignant des dimensions aux lignes, colonnes et filtres"
|
||||
"Catégories dans le temps suit l'évolution des dépenses par catégorie"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -745,7 +734,6 @@
|
|||
"Dépenses par catégorie : répartition des dépenses (graphique circulaire)",
|
||||
"Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)",
|
||||
"Budget vs Réel : tableau comparatif mensuel et cumul annuel",
|
||||
"Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable",
|
||||
"Motifs SVG (lignes, points, hachures) pour distinguer les catégories",
|
||||
"Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions",
|
||||
"Détail des transactions par catégorie avec tri par colonne (date, description, montant)",
|
||||
|
|
@ -848,7 +836,8 @@
|
|||
"total": "Total",
|
||||
"darkMode": "Mode sombre",
|
||||
"lightMode": "Mode clair",
|
||||
"close": "Fermer"
|
||||
"close": "Fermer",
|
||||
"underConstruction": "En construction"
|
||||
},
|
||||
"license": {
|
||||
"title": "Licence",
|
||||
|
|
|
|||
11
src/pages/ReportsCategoryPage.tsx
Normal file
11
src/pages/ReportsCategoryPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ReportsCategoryPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-8 text-center text-[var(--muted-foreground)]">
|
||||
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.categoryZoom")}</h1>
|
||||
<p>{t("common.underConstruction")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/pages/ReportsComparePage.tsx
Normal file
11
src/pages/ReportsComparePage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ReportsComparePage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-8 text-center text-[var(--muted-foreground)]">
|
||||
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.compare")}</h1>
|
||||
<p>{t("common.underConstruction")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/pages/ReportsHighlightsPage.tsx
Normal file
11
src/pages/ReportsHighlightsPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ReportsHighlightsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-8 text-center text-[var(--muted-foreground)]">
|
||||
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.highlights")}</h1>
|
||||
<p>{t("common.underConstruction")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,16 +13,15 @@ import CategoryTable from "../components/reports/CategoryTable";
|
|||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
|
||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||
import DynamicReport from "../components/reports/DynamicReport";
|
||||
import ReportFilterPanel from "../components/reports/ReportFilterPanel";
|
||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||
|
||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual", "dynamic"];
|
||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId, setCategoryType } = useReports();
|
||||
const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setSourceId, setCategoryType } = useReports();
|
||||
const [sources, setSources] = useState<ImportSource[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -127,8 +126,8 @@ export default function ReportsPage() {
|
|||
<>
|
||||
<div className="mx-1 h-6 w-px bg-[var(--border)]" />
|
||||
{([
|
||||
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
|
||||
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
|
||||
{ mode: "chart" as const, icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
|
||||
{ mode: "table" as const, icon: <Table size={14} />, label: t("reports.viewMode.table") },
|
||||
]).map(({ mode, icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
|
|
@ -219,13 +218,6 @@ export default function ReportsPage() {
|
|||
{state.tab === "budgetVsActual" && (
|
||||
<BudgetVsActualTable data={state.budgetVsActual} />
|
||||
)}
|
||||
{state.tab === "dynamic" && (
|
||||
<DynamicReport
|
||||
config={state.pivotConfig}
|
||||
result={state.pivotResult}
|
||||
onConfigChange={setPivotConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showFilterPanel && (
|
||||
<ReportFilterPanel
|
||||
|
|
|
|||
11
src/pages/ReportsTrendsPage.tsx
Normal file
11
src/pages/ReportsTrendsPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ReportsTrendsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-8 text-center text-[var(--muted-foreground)]">
|
||||
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.trends")}</h1>
|
||||
<p>{t("common.underConstruction")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,10 +4,6 @@ import type {
|
|||
CategoryBreakdownItem,
|
||||
CategoryOverTimeData,
|
||||
CategoryOverTimeItem,
|
||||
PivotConfig,
|
||||
PivotFieldId,
|
||||
PivotResult,
|
||||
PivotResultRow,
|
||||
} from "../shared/types";
|
||||
|
||||
export async function getMonthlyTrends(
|
||||
|
|
@ -170,189 +166,3 @@ export async function getCategoryOverTime(
|
|||
categoryIds,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Dynamic Report (Pivot Table) ---
|
||||
|
||||
const FIELD_SQL: Record<PivotFieldId, { select: string; alias: string }> = {
|
||||
year: { select: "strftime('%Y', t.date)", alias: "year" },
|
||||
month: { select: "strftime('%Y-%m', t.date)", alias: "month" },
|
||||
type: { select: "COALESCE(c.type, 'expense')", alias: "type" },
|
||||
level1: { select: "COALESCE(grandparent_cat.name, parent_cat.name, c.name, 'Uncategorized')", alias: "level1" },
|
||||
level2: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN parent_cat.name WHEN parent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level2" },
|
||||
level3: { select: "CASE WHEN grandparent_cat.id IS NOT NULL THEN c.name ELSE NULL END", alias: "level3" },
|
||||
};
|
||||
|
||||
function needsCategoryJoin(fields: PivotFieldId[]): boolean {
|
||||
return fields.some((f) => f === "type" || f === "level1" || f === "level2" || f === "level3");
|
||||
}
|
||||
|
||||
export async function getDynamicReportData(
|
||||
config: PivotConfig,
|
||||
): Promise<PivotResult> {
|
||||
const db = await getDb();
|
||||
|
||||
const allDimensions = [...config.rows, ...config.columns];
|
||||
const filterFields = Object.keys(config.filters) as PivotFieldId[];
|
||||
const allFields = [...new Set([...allDimensions, ...filterFields])];
|
||||
|
||||
const useCatJoin = needsCategoryJoin(allFields);
|
||||
|
||||
// Build SELECT columns
|
||||
const selectParts: string[] = [];
|
||||
const groupByParts: string[] = [];
|
||||
|
||||
for (const fieldId of allDimensions) {
|
||||
const def = FIELD_SQL[fieldId];
|
||||
selectParts.push(`${def.select} AS ${def.alias}`);
|
||||
groupByParts.push(def.alias);
|
||||
}
|
||||
|
||||
// Measures
|
||||
const hasPeriodic = config.values.includes("periodic");
|
||||
const hasYtd = config.values.includes("ytd");
|
||||
|
||||
if (hasPeriodic) {
|
||||
selectParts.push("ABS(SUM(t.amount)) AS periodic");
|
||||
}
|
||||
|
||||
// Build WHERE
|
||||
const whereClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Apply filter values (include / exclude)
|
||||
for (const fieldId of filterFields) {
|
||||
const entry = config.filters[fieldId];
|
||||
if (!entry) continue;
|
||||
const def = FIELD_SQL[fieldId as PivotFieldId];
|
||||
if (entry.include && entry.include.length > 0) {
|
||||
const placeholders = entry.include.map(() => {
|
||||
const p = `$${paramIndex}`;
|
||||
paramIndex++;
|
||||
return p;
|
||||
});
|
||||
whereClauses.push(`${def.select} IN (${placeholders.join(", ")})`);
|
||||
params.push(...entry.include);
|
||||
}
|
||||
if (entry.exclude && entry.exclude.length > 0) {
|
||||
const placeholders = entry.exclude.map(() => {
|
||||
const p = `$${paramIndex}`;
|
||||
paramIndex++;
|
||||
return p;
|
||||
});
|
||||
whereClauses.push(`${def.select} NOT IN (${placeholders.join(", ")})`);
|
||||
params.push(...entry.exclude);
|
||||
}
|
||||
}
|
||||
|
||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
const groupBySQL = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(", ")}` : "";
|
||||
const orderBySQL = groupByParts.length > 0 ? `ORDER BY ${groupByParts.join(", ")}` : "";
|
||||
|
||||
const joinSQL = useCatJoin
|
||||
? `LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id
|
||||
LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_cat.id`
|
||||
: "";
|
||||
|
||||
const sql = `SELECT ${selectParts.join(", ")}
|
||||
FROM transactions t
|
||||
${joinSQL}
|
||||
${whereSQL}
|
||||
${groupBySQL}
|
||||
${orderBySQL}`;
|
||||
|
||||
const rawRows = await db.select<Array<Record<string, unknown>>>(sql, params);
|
||||
|
||||
// Build PivotResultRow array
|
||||
const rows: PivotResultRow[] = rawRows.map((raw) => {
|
||||
const keys: Record<string, string> = {};
|
||||
for (const fieldId of allDimensions) {
|
||||
keys[fieldId] = String(raw[FIELD_SQL[fieldId].alias] ?? "");
|
||||
}
|
||||
const measures: Record<string, number> = {};
|
||||
if (hasPeriodic) {
|
||||
measures.periodic = Number(raw.periodic) || 0;
|
||||
}
|
||||
return { keys, measures };
|
||||
});
|
||||
|
||||
// Compute YTD if requested
|
||||
if (hasYtd && rows.length > 0) {
|
||||
// YTD = cumulative sum from January of the year, grouped by row dimensions (excluding month)
|
||||
const rowDims = config.rows.filter((f) => f !== "month");
|
||||
const colDims = config.columns.filter((f) => f !== "month");
|
||||
const groupDims = [...rowDims, ...colDims];
|
||||
|
||||
// Sort rows by year then month for accumulation
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aKey = (a.keys.year || a.keys.month?.slice(0, 4) || "") + (a.keys.month || "");
|
||||
const bKey = (b.keys.year || b.keys.month?.slice(0, 4) || "") + (b.keys.month || "");
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
|
||||
// Accumulate by group key + year
|
||||
const accumulators = new Map<string, number>();
|
||||
for (const row of sorted) {
|
||||
const year = row.keys.year || row.keys.month?.slice(0, 4) || "";
|
||||
const groupKey = groupDims.map((d) => row.keys[d] || "").join("|") + "|" + year;
|
||||
const prev = accumulators.get(groupKey) || 0;
|
||||
const current = prev + (row.measures.periodic || 0);
|
||||
accumulators.set(groupKey, current);
|
||||
row.measures.ytd = current;
|
||||
}
|
||||
|
||||
// Restore original order
|
||||
const rowMap = new Map(sorted.map((r) => {
|
||||
const key = Object.values(r.keys).join("|");
|
||||
return [key, r];
|
||||
}));
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const key = Object.values(rows[i].keys).join("|");
|
||||
const updated = rowMap.get(key);
|
||||
if (updated) {
|
||||
rows[i].measures.ytd = updated.measures.ytd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract distinct column values (composite key when multiple column dimensions)
|
||||
const colDims = config.columns;
|
||||
const columnValues = colDims.length > 0
|
||||
? [...new Set(rows.map((r) => colDims.map((d) => r.keys[d] || "").join("\0")))].sort()
|
||||
: [];
|
||||
|
||||
// Dimension labels
|
||||
const dimensionLabels: Record<string, string> = {
|
||||
year: "Année",
|
||||
month: "Mois",
|
||||
type: "Type",
|
||||
level1: "Catégorie (Niveau 1)",
|
||||
level2: "Catégorie (Niveau 2)",
|
||||
level3: "Catégorie (Niveau 3)",
|
||||
periodic: "Montant périodique",
|
||||
ytd: "Cumul annuel (YTD)",
|
||||
};
|
||||
|
||||
return { rows, columnValues, dimensionLabels };
|
||||
}
|
||||
|
||||
export async function getDynamicFilterValues(
|
||||
fieldId: PivotFieldId,
|
||||
): Promise<string[]> {
|
||||
const db = await getDb();
|
||||
const def = FIELD_SQL[fieldId];
|
||||
const useCatJoin = needsCategoryJoin([fieldId]);
|
||||
|
||||
const joinSQL = useCatJoin
|
||||
? `LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN categories parent_cat ON c.parent_id = parent_cat.id
|
||||
LEFT JOIN categories grandparent_cat ON parent_cat.parent_id = grandparent_cat.id`
|
||||
: "";
|
||||
|
||||
const rows = await db.select<Array<{ val: string }>>(
|
||||
`SELECT DISTINCT ${def.select} AS val FROM transactions t ${joinSQL} ORDER BY val`,
|
||||
[],
|
||||
);
|
||||
return rows.map((r) => r.val);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,36 +276,7 @@ export interface RecentTransaction {
|
|||
|
||||
// --- Report Types ---
|
||||
|
||||
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual" | "dynamic";
|
||||
|
||||
// --- Pivot / Dynamic Report Types ---
|
||||
|
||||
export type PivotFieldId = "year" | "month" | "type" | "level1" | "level2" | "level3";
|
||||
export type PivotMeasureId = "periodic" | "ytd";
|
||||
export type PivotZone = "rows" | "columns" | "filters" | "values";
|
||||
|
||||
export interface PivotFilterEntry {
|
||||
include: string[]; // included values (empty = all)
|
||||
exclude: string[]; // excluded values
|
||||
}
|
||||
|
||||
export interface PivotConfig {
|
||||
rows: PivotFieldId[];
|
||||
columns: PivotFieldId[];
|
||||
filters: Record<string, PivotFilterEntry>; // field → include/exclude entries
|
||||
values: PivotMeasureId[];
|
||||
}
|
||||
|
||||
export interface PivotResultRow {
|
||||
keys: Record<string, string>; // dimension values
|
||||
measures: Record<string, number>; // measure values
|
||||
}
|
||||
|
||||
export interface PivotResult {
|
||||
rows: PivotResultRow[];
|
||||
columnValues: string[]; // distinct values for column dimension
|
||||
dimensionLabels: Record<string, string>; // field id → display label
|
||||
}
|
||||
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
||||
|
||||
export interface MonthlyTrendItem {
|
||||
month: string; // "2025-01"
|
||||
|
|
|
|||
Loading…
Reference in a new issue