Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- #42: Allow spools heavier than theoretical max (remove weight clamps) - #43: Show filament custom fields on spool detail page - #25: Sort spools by custom fields and remaining weight - #35: Global search box for spool list - #20: Gallery view for spools (color grid with progress bars) - #36: Spool-level color override with ColorPicker - #39: Cost analytics endpoint, total spent & avg cost/kg stats - Fix: Filament select refresh after inline creation - Fix: Parse temperatures from filament comments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined, RiseOutlined } from "@ant-design/icons";
|
|
import { useList, useTranslate } from "@refinedev/core";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Card, Col, Row, Statistic, theme } from "antd";
|
|
import { useMemo } from "react";
|
|
import { Link } from "react-router";
|
|
import { IFilament } from "../../filaments/model";
|
|
import { ISpool } from "../../spools/model";
|
|
import { IVendor } from "../../vendors/model";
|
|
import { useCurrencyFormatter } from "../../../utils/settings";
|
|
import { getAPIURL } from "../../../utils/url";
|
|
|
|
const { useToken } = theme;
|
|
|
|
export function QuickStats() {
|
|
const t = useTranslate();
|
|
const { token } = useToken();
|
|
const currencyFormatter = useCurrencyFormatter();
|
|
|
|
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
|
|
resource: "filament",
|
|
pagination: { pageSize: 1 },
|
|
});
|
|
|
|
const { result: vendorsResult, query: vendorsQuery } = useList<IVendor>({
|
|
resource: "vendor",
|
|
pagination: { pageSize: 1 },
|
|
});
|
|
|
|
// Fetch all spools for total weight calculation
|
|
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
|
|
resource: "spool",
|
|
pagination: { pageSize: 1000 },
|
|
meta: {
|
|
queryParams: {
|
|
allow_archived: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Calculate total remaining weight
|
|
const totalWeight = useMemo(() => {
|
|
const data = spoolsResult?.data || [];
|
|
return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0);
|
|
}, [spoolsResult?.data]);
|
|
|
|
// Calculate total inventory value
|
|
const totalValue = useMemo(() => {
|
|
const data = spoolsResult?.data || [];
|
|
return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0);
|
|
}, [spoolsResult?.data]);
|
|
|
|
// Fetch cost stats from backend
|
|
const { data: costStats, isLoading: costLoading } = useQuery<{
|
|
total_spent: number;
|
|
total_weight_purchased: number;
|
|
avg_cost_per_kg: number | null;
|
|
}>({
|
|
queryKey: ["analytics", "cost"],
|
|
queryFn: async () => {
|
|
const res = await fetch(`${getAPIURL()}/analytics/cost`);
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
const formatWeight = (grams: number) => {
|
|
if (grams >= 1000) {
|
|
return `${(grams / 1000).toFixed(1)} kg`;
|
|
}
|
|
return `${Math.round(grams)} g`;
|
|
};
|
|
|
|
return (
|
|
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
|
|
<Col xs={12} md={8} lg={4}>
|
|
<Link to="/spool">
|
|
<Card hoverable size="small">
|
|
<Statistic
|
|
title={t("home.quick_stats.total_spools")}
|
|
value={spoolsResult?.total ?? 0}
|
|
loading={spoolsQuery.isLoading}
|
|
prefix={<InboxOutlined />}
|
|
/>
|
|
</Card>
|
|
</Link>
|
|
</Col>
|
|
<Col xs={12} md={8} lg={4}>
|
|
<Link to="/filament">
|
|
<Card hoverable size="small">
|
|
<Statistic
|
|
title={t("home.quick_stats.total_filaments")}
|
|
value={filamentsResult?.total ?? 0}
|
|
loading={filamentsQuery.isLoading}
|
|
prefix={<HighlightOutlined />}
|
|
/>
|
|
</Card>
|
|
</Link>
|
|
</Col>
|
|
<Col xs={12} md={8} lg={4}>
|
|
<Link to="/vendor">
|
|
<Card hoverable size="small">
|
|
<Statistic
|
|
title={t("home.quick_stats.total_vendors")}
|
|
value={vendorsResult?.total ?? 0}
|
|
loading={vendorsQuery.isLoading}
|
|
prefix={<UserOutlined />}
|
|
/>
|
|
</Card>
|
|
</Link>
|
|
</Col>
|
|
<Col xs={12} md={12} lg={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title={t("home.quick_stats.total_weight")}
|
|
value={formatWeight(totalWeight)}
|
|
loading={spoolsQuery.isLoading}
|
|
prefix={<DatabaseOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} md={12} lg={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title={t("home.quick_stats.total_value")}
|
|
value={currencyFormatter.format(totalValue)}
|
|
loading={spoolsQuery.isLoading}
|
|
prefix={<DollarOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} md={12} lg={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title={t("home.quick_stats.total_spent")}
|
|
value={currencyFormatter.format(costStats?.total_spent ?? 0)}
|
|
loading={costLoading}
|
|
prefix={<DollarOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} md={12} lg={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title={t("home.quick_stats.avg_cost_per_kg")}
|
|
value={costStats?.avg_cost_per_kg != null ? currencyFormatter.format(costStats.avg_cost_per_kg) : "-"}
|
|
loading={costLoading}
|
|
prefix={<RiseOutlined />}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
);
|
|
}
|