Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion frontend/src/pages/Sales/RevenueAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatUAH, formatNumber } from '../../utils/format';
import { Card, Col, Row, Select, Statistic, Empty, message, Tag } from 'antd';
import { DollarOutlined, ShoppingOutlined, UserOutlined, TrophyOutlined } from '@ant-design/icons';
Expand Down Expand Up @@ -29,6 +30,7 @@ const PIE_COLORS = chartPalette;

export default function RevenueAnalytics() {
const { t, lang } = useTranslation();
const navigate = useNavigate();
const [year, setYear] = useState<number>(new Date().getFullYear());
const [data, setData] = useState<SalesAnalyticsDto | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -58,9 +60,24 @@ export default function RevenueAnalytics() {

const monthlyChartData = (data?.byMonth ?? []).map((m) => ({
name: `${monthNames[m.month - 1]} ${m.year}`,
year: m.year,
month: m.month,
[t.sales.revenueAmount]: m.totalAmount,
}));

/** Drill-down from the monthly revenue chart → /sales pre-filtered to the
* clicked month. Uses the explicit `year`/`month` fields on each data
* point so we don't re-parse the localised label. */
const handleMonthlyBarClick = (state: { activePayload?: Array<{ payload?: { year?: number; month?: number } }> } | null) => {
const p = state?.activePayload?.[0]?.payload;
if (!p?.year || !p?.month) return;
const mm = String(p.month).padStart(2, '0');
const lastDay = new Date(Date.UTC(p.year, p.month, 0)).getUTCDate();
const from = `${p.year}-${mm}-01`;
const to = `${p.year}-${mm}-${String(lastDay).padStart(2, '0')}`;
navigate(`/sales?from=${from}&to=${to}`);
};

const productColumns = [
{
title: t.sales.product,
Expand Down Expand Up @@ -267,7 +284,12 @@ export default function RevenueAnalytics() {
>
{monthlyChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={monthlyChartData} margin={{ top: 5, right: 20, left: 20, bottom: 40 }}>
<BarChart
data={monthlyChartData}
margin={{ top: 5, right: 20, left: 20, bottom: 40 }}
onClick={handleMonthlyBarClick}
style={{ cursor: 'pointer' }}
>
<CartesianGrid strokeDasharray={chartConfig.grid.strokeDasharray} stroke={chartConfig.grid.stroke} vertical={chartConfig.grid.vertical} />
<XAxis
dataKey="name"
Expand Down
38 changes: 34 additions & 4 deletions frontend/src/pages/Sales/SalesList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Space, DatePicker, message, Button, Modal, Form, Input, InputNumber, Select } from 'antd';
import dayjs from 'dayjs';
import { PlusOutlined, EditOutlined } from '@ant-design/icons';
import { getSales, createSale, updateSale, deleteSale } from '../../api/sales';
import { getFields } from '../../api/fields';
Expand All @@ -22,11 +24,22 @@ const { RangePicker } = DatePicker;
const UNITS = ['т', 'кг', 'л', 'шт'];

export default function SalesList() {
// Accept ?from=YYYY-MM-DD&to=YYYY-MM-DD&search=... so drill-downs from the
// Sales revenue chart (and any other deep link) pre-fill the filters and
// survive refresh / sharing.
const [searchParams, setSearchParams] = useSearchParams();
const isIsoDate = (v: string | null): v is string => !!v && /^\d{4}-\d{2}-\d{2}$/.test(v);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate calendar dates before accepting URL filters

isIsoDate only checks the YYYY-MM-DD shape, so impossible values like 2026-02-31 are treated as valid and copied into dateRange. Those values are then sent to getSales(...), while the API endpoint binds dateFrom/dateTo as DateTime? (src/AgroPlatform.Api/Controllers/SalesController.cs), which can reject malformed calendar dates and break deep links with a typo. Use strict calendar validation (e.g., strict dayjs parse) before initializing filters from query params.

Useful? React with 👍 / 👎.

const urlFrom = searchParams.get('from');
const urlTo = searchParams.get('to');
const urlSearch = searchParams.get('search') || undefined;
const initialRange: [string, string] | null =
isIsoDate(urlFrom) && isIsoDate(urlTo) ? [urlFrom, urlTo] : null;

const [result, setResult] = useState<PaginatedResult<SaleDto> | null>(null);
const [fields, setFields] = useState<FieldDto[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
const [search, setSearch] = useState<string | undefined>(urlSearch);
const [dateRange, setDateRange] = useState<[string, string] | null>(initialRange);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);

Expand Down Expand Up @@ -233,14 +246,31 @@ export default function SalesList() {
<Input
placeholder={t.sales.searchBuyer}
value={search}
onChange={(e) => { setSearch(e.target.value || undefined); setPage(1); }}
onChange={(e) => {
const v = e.target.value || undefined;
setSearch(v);
setPage(1);
setSearchParams((prev) => {
const p = new URLSearchParams(prev);
if (v) p.set('search', v); else p.delete('search');
return p;
}, { replace: true });
}}
style={{ width: 240 }}
allowClear
/>
<RangePicker
value={dateRange ? [dayjs(dateRange[0]), dayjs(dateRange[1])] : null}
onChange={(_, dates) => {
setDateRange(dates[0] && dates[1] ? [dates[0], dates[1]] : null);
const next: [string, string] | null = dates[0] && dates[1] ? [dates[0], dates[1]] : null;
setDateRange(next);
setPage(1);
setSearchParams((prev) => {
const p = new URLSearchParams(prev);
if (next) { p.set('from', next[0]); p.set('to', next[1]); }
else { p.delete('from'); p.delete('to'); }
return p;
}, { replace: true });
}}
/>
{canWrite && (
Expand Down
Loading