Compare commits
12 Commits
1.2.1
...
3837c9239e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3837c9239e | |||
| d979e443be | |||
| e8faafae7d | |||
| 6b340d89f6 | |||
| 79808d6d3e | |||
| f865f7dec2 | |||
| fc3a48a367 | |||
| f9759e3968 | |||
| 47c00a06a8 | |||
| a107029b90 | |||
| baa9b296cb | |||
| f08bc72037 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,4 +14,3 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.idea
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box, Container, Typography, Paper, Button, Alert,
|
||||
CircularProgress, IconButton, Tooltip,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
|
||||
import type { FieldConfig } from "../../react-openapi";
|
||||
import { PageHeader } from "../ui/PageHeader";
|
||||
import { EmptyState } from "../ui/EmptyState";
|
||||
import { formatApiError } from "../features/fetch-requests";
|
||||
import { useToast } from "../ui/Toast";
|
||||
|
||||
const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
|
||||
|
||||
function FetchRequestList() {
|
||||
const navigate = useNavigate();
|
||||
const { list, remove, resource } = useResource("fetch-requests");
|
||||
const { list, resource } = useResource("fetch-requests");
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [rows, setRows] = useState<any[] | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const reload = () => {
|
||||
if (!resource) return;
|
||||
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!resource) return [];
|
||||
@@ -40,26 +30,6 @@ function FetchRequestList() {
|
||||
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
|
||||
}, [resource?.name]);
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
if (
|
||||
!window.confirm(
|
||||
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await remove(id);
|
||||
showToast("Fetch request deleted");
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
showToast(formatApiError(err), "error");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!rows) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||
@@ -112,18 +82,6 @@ function FetchRequestList() {
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ ml: "auto", flexShrink: 0 }}>
|
||||
<Tooltip title="Delete fetch request">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={deletingId === row.id || row.status === "processing"}
|
||||
onClick={(e) => handleDelete(e, row.id)}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
@@ -136,7 +94,6 @@ export default function FetchRequestCreate() {
|
||||
const { resources: allResources } = useAppContext();
|
||||
const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]);
|
||||
const { create } = useResource("fetch-requests");
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -186,10 +143,6 @@ export default function FetchRequestCreate() {
|
||||
const display = applyDisplayFormat(created, resource!.displayFormat);
|
||||
setResult({ severity: "success", message: `Created: ${display}` });
|
||||
setFormData({});
|
||||
const newId = (created as any)?.id;
|
||||
if (newId) {
|
||||
navigate(`/fetch-requests/${newId}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const detail = e?.response?.data?.detail;
|
||||
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error");
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
@@ -19,7 +18,6 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
|
||||
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
|
||||
@@ -87,12 +85,11 @@ function Section({ title, children, action }: { title: string; children: React.R
|
||||
export default function FetchRequestDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { get, patch, remove, resource } = useResource("fetch-requests");
|
||||
const { get, patch, resource } = useResource("fetch-requests");
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [stepStats, setStepStats] = useState<Record<string, number>>({});
|
||||
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const feedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -177,26 +174,6 @@ export default function FetchRequestDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id || !remove || deleting) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await remove(id);
|
||||
showToast("Fetch request deleted");
|
||||
navigate("/fetch-requests");
|
||||
} catch (err: any) {
|
||||
showToast(formatApiError(err), "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const req = fetchRequest as any;
|
||||
const retryCount = req?.retry_count ?? 0;
|
||||
const isRetryExhausted = retryCount >= RETRY_MAX;
|
||||
@@ -309,20 +286,6 @@ export default function FetchRequestDetail() {
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip title={status === "processing" ? "Cannot delete while processing" : "Delete fetch request"}>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteOutlineIcon />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || status === "processing"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -29,12 +29,7 @@ function computeProgressPercent(
|
||||
|
||||
let pct = 0;
|
||||
|
||||
if (
|
||||
seenSteps.has("raw_lines") ||
|
||||
seenSteps.has("txn_blocks")
|
||||
) {
|
||||
pct += 10;
|
||||
}
|
||||
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
|
||||
|
||||
if (txnBlockCount > 0) {
|
||||
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Paper, Typography, Button, IconButton, Alert, Skeleton, Chip, MenuItem, Select, FormControl, InputLabel } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CachedIcon from "@mui/icons-material/Cached";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import { FkMultiSelectField, useResource, formatCurrency } from "../../react-openapi";
|
||||
import type { FieldConfig } from "../../react-openapi";
|
||||
import { StatCard } from "../common/components/StatCard";
|
||||
import { TransactionList } from "../common/components/TransactionList";
|
||||
import type { TxnFieldConfigs } from "../common/types";
|
||||
import type { ListPeriodGroup } from "../common/utils/transactions";
|
||||
import { buildDimensionBars, buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types";
|
||||
import type { ReportDim } from "./types";
|
||||
|
||||
const MAX_VISIBLE_BARS = 10;
|
||||
import { buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types";
|
||||
|
||||
const periodField: FieldConfig = {
|
||||
name: "period",
|
||||
@@ -83,7 +78,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
||||
const [reload, setReload] = useState(0);
|
||||
const [granularity, setGranularity] = useState<string | null>(null);
|
||||
const [flow, setFlow] = useState("outflows");
|
||||
const [dim, setDim] = useState<ReportDim>("period");
|
||||
const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
|
||||
const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
@@ -93,14 +87,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
||||
const p: Record<string, any> = { flow };
|
||||
if (granularity) p.granularity = [granularity];
|
||||
if (selectedPeriods.length) p.period_ids = selectedPeriods;
|
||||
// The active breakdown dim requests every cube value ("*") when its own
|
||||
// filter is unselected, so bars show the full distribution.
|
||||
if (selectedPayees.length) p.payee = selectedPayees;
|
||||
else if (dim === "payee") p.payee = ["*"];
|
||||
if (selectedTags.length) p.tags = selectedTags;
|
||||
else if (dim === "tag") p.tags = ["*"];
|
||||
return p;
|
||||
}, [granularity, flow, dim, selectedPeriods, selectedPayees, selectedTags]);
|
||||
}, [granularity, flow, selectedPeriods, selectedPayees, selectedTags]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -155,8 +145,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
||||
|
||||
const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]);
|
||||
const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]);
|
||||
const bars = useMemo(() => buildDimensionBars(report?.buckets ?? [], filter, dim), [report, filter, dim]);
|
||||
const visibleBars = useMemo(() => bars.slice(0, MAX_VISIBLE_BARS), [bars]);
|
||||
const listGroups = useMemo<ListPeriodGroup[]>(
|
||||
() =>
|
||||
periodGroups.map((g) => ({
|
||||
@@ -179,32 +167,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
||||
[periodGroups],
|
||||
);
|
||||
|
||||
const barsListRef = useRef<HTMLDivElement>(null);
|
||||
const [barScroll, setBarScroll] = useState({ canUp: false, canDown: false, overflow: false });
|
||||
|
||||
const updateBarScroll = useCallback(() => {
|
||||
const el = barsListRef.current;
|
||||
if (!el) return;
|
||||
const overflow = el.scrollHeight > el.clientHeight + 2;
|
||||
setBarScroll({
|
||||
overflow,
|
||||
canUp: overflow && el.scrollTop > 2,
|
||||
canDown: overflow && el.scrollTop < el.scrollHeight - el.clientHeight - 2,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = barsListRef.current;
|
||||
if (el) el.scrollTop = 0;
|
||||
updateBarScroll();
|
||||
}, [dim, visibleBars.length, updateBarScroll]);
|
||||
|
||||
const scrollBarsByPage = useCallback((dir: number) => {
|
||||
const el = barsListRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ top: dir > 0 ? el.scrollHeight : 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
if (loading && !report) {
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
|
||||
@@ -231,16 +193,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
||||
if (!report) return null;
|
||||
|
||||
const activeGranularity = granularity ?? report.granularities?.[0] ?? "";
|
||||
const maxBar = bars.reduce((m, b) => Math.max(m, Math.abs(b.sum)), 0);
|
||||
const dimOptions: { id: ReportDim; label: string; count: number }[] = [
|
||||
{ id: "period", label: "Period", count: report.period_ids?.length ?? 0 },
|
||||
{ id: "payee", label: "Payees", count: report.payees?.length ?? 0 },
|
||||
{ id: "tag", label: "Tags", count: report.tags?.length ?? 0 },
|
||||
];
|
||||
const activeDimIndex = Math.max(
|
||||
0,
|
||||
dimOptions.findIndex((d) => d.id === dim),
|
||||
);
|
||||
const maxBar = periodGroups.reduce((m, g) => Math.max(m, g.metrics.sum), 0);
|
||||
const range =
|
||||
report.query?.start_date || report.query?.end_date
|
||||
? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}`
|
||||
@@ -350,148 +303,39 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
||||
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
width: "fit-content",
|
||||
maxWidth: "100%",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: "999px",
|
||||
p: 0.5,
|
||||
backgroundColor: "background.default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
width: `calc((100% - 8px) / ${dimOptions.length})`,
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "primary.main",
|
||||
transform: `translateX(${activeDimIndex * 100}%)`,
|
||||
transition: "transform 250ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
{dimOptions.map((d) => {
|
||||
const active = d.id === dim;
|
||||
return (
|
||||
{periodGroups.length === 0 ? (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mb: 2.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No data for this slice. Try another granularity, period or payer.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 2, p: 2, mb: 2.5 }}>
|
||||
{periodGroups.map((g) => (
|
||||
<Box key={g.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
|
||||
{g.key}
|
||||
</Typography>
|
||||
<Box
|
||||
key={d.id}
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={d.count <= 1}
|
||||
onClick={() => setDim(d.id)}
|
||||
sx={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
px: 3,
|
||||
py: 1,
|
||||
borderRadius: "999px",
|
||||
cursor: d.count <= 1 ? "default" : "pointer",
|
||||
typography: "body2",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.01em",
|
||||
color:
|
||||
d.count <= 1
|
||||
? "text.disabled"
|
||||
: active
|
||||
? "primary.contrastText"
|
||||
: "text.secondary",
|
||||
whiteSpace: "nowrap",
|
||||
transition: "color 160ms ease",
|
||||
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" },
|
||||
height: 20,
|
||||
borderRadius: 1,
|
||||
bgcolor: flow === "outflows" ? "error.main" : "success.main",
|
||||
opacity: 0.85,
|
||||
minWidth: 4,
|
||||
}}
|
||||
>
|
||||
{d.label}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{visibleBars.length === 0 ? (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mt: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No data for this slice. Try another granularity, period or payer.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 2, mt: 1.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "stretch" }}>
|
||||
<Box
|
||||
ref={barsListRef}
|
||||
onScroll={updateBarScroll}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
maxHeight: 220,
|
||||
overflowY: "auto",
|
||||
py: 1,
|
||||
px: 2,
|
||||
scrollbarWidth: "none",
|
||||
"&::-webkit-scrollbar": { display: "none" },
|
||||
}}
|
||||
>
|
||||
{visibleBars.map((b) => (
|
||||
<Box key={b.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
|
||||
{b.key}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
height: 20,
|
||||
borderRadius: 1,
|
||||
bgcolor: flow === "outflows" ? "error.main" : "success.main",
|
||||
opacity: 0.85,
|
||||
minWidth: 4,
|
||||
flexGrow: 0,
|
||||
}}
|
||||
style={{
|
||||
width: `${maxBar ? Math.max((Math.abs(b.sum) / maxBar) * 100, 2) : 2}%`,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{formatCurrency(b.sum, slice.currency)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{b.count} txn{b.count === 1 ? "" : "s"}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{barScroll.overflow && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: 0.5, pr: 1.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Scroll bars up"
|
||||
disabled={!barScroll.canUp}
|
||||
onClick={() => scrollBarsByPage(-1)}
|
||||
>
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Scroll bars down"
|
||||
disabled={!barScroll.canDown}
|
||||
onClick={() => scrollBarsByPage(1)}
|
||||
>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
style={{ width: `${maxBar ? Math.max((g.metrics.sum / maxBar) * 100, 2) : 2}%` }}
|
||||
/>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{formatCurrency(g.metrics.sum, slice.currency)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{slice.txns.length === 0 ? null : fields ? (
|
||||
<TransactionList fields={fields} groups={listGroups} showMetrics />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FieldConfig, ResourceConfig } from "../../react-openapi";
|
||||
import { parseOccurredAt } from "../common/utils/dates";
|
||||
|
||||
export interface PeriodMetricsVM {
|
||||
outflows: number;
|
||||
@@ -80,11 +79,8 @@ export function metricLabels(schemas: Record<string, any>): MetricLabel[] {
|
||||
}
|
||||
|
||||
function dateVal(value: string): number {
|
||||
try {
|
||||
return parseOccurredAt(value).getTime();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const t = new Date(value).getTime();
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
function bucketMatches(bucket: any, filter: SliceFilter): boolean {
|
||||
@@ -179,13 +175,7 @@ export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPe
|
||||
vm.avg = vm.count ? Math.round((vm.sum / vm.count) * 100) / 100 : null;
|
||||
if (acc.sources > 1) {
|
||||
const dates = acc.txns
|
||||
.map((t) => {
|
||||
try {
|
||||
return parseOccurredAt(t?.occurred_at).getTime();
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
})
|
||||
.map((t) => new Date(t?.occurred_at ?? "").getTime())
|
||||
.filter((t) => !Number.isNaN(t))
|
||||
.sort((a, b) => a - b);
|
||||
if (dates.length >= 2) {
|
||||
@@ -241,48 +231,4 @@ export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFiel
|
||||
const generatedAt = find("generated_at");
|
||||
if (!name || !generatedAt) return null;
|
||||
return { name, generatedAt };
|
||||
}
|
||||
|
||||
export type ReportDim = "period" | "payee" | "tag";
|
||||
|
||||
export interface DimensionBar {
|
||||
key: string;
|
||||
sum: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar-chart rows for a given dimension. "period" reuses the period groups;
|
||||
* "payee"/"tag" fold each bucket's metrics into every value listed under
|
||||
* group_key[dim] — safe because server buckets are disjoint slices.
|
||||
*/
|
||||
export function buildDimensionBars(buckets: any[], filter: SliceFilter, dim: ReportDim): DimensionBar[] {
|
||||
if (dim === "period") {
|
||||
return buildPeriodGroups(buckets, filter).map((g) => ({
|
||||
key: g.key,
|
||||
sum: g.metrics.sum,
|
||||
count: g.metrics.count,
|
||||
}));
|
||||
}
|
||||
const acc = new Map<string, DimensionBar>();
|
||||
for (const bucket of buckets ?? []) {
|
||||
if (!bucketMatches(bucket, filter)) continue;
|
||||
const keys: string[] = bucket.group_key?.[dim] ?? [];
|
||||
for (const period of bucket.series?.[filter.granularity] ?? []) {
|
||||
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
|
||||
const m = period.metrics ?? {};
|
||||
const sum = typeof m.sum === "number" ? m.sum : 0;
|
||||
const count = typeof m.count === "number" ? m.count : 0;
|
||||
for (const k of keys) {
|
||||
const cur = acc.get(k);
|
||||
if (cur) {
|
||||
cur.sum += sum;
|
||||
cur.count += count;
|
||||
} else {
|
||||
acc.set(k, { key: k, sum, count });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...acc.values()].sort((a, b) => Math.abs(b.sum) - Math.abs(a.sum));
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export interface EmailSource {
|
||||
txn_dicts_count?: number;
|
||||
}
|
||||
|
||||
export type PipelineType = "heuristic" | "llm" | "llama_parse";
|
||||
export type PipelineType = "heuristic" | "llm" | "llama_parser";
|
||||
|
||||
export interface FetchRequestCreate {
|
||||
source: FileSource | EmailSource;
|
||||
|
||||
Reference in New Issue
Block a user