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
1 change: 1 addition & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ const en: Translations = {
week: 'Week',
month: 'Month',
season: 'Season',
allTime: 'All time',
commandCenter: 'Command Center',
atGlance: 'At a glance',
financialOverview: 'Financial overview',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/uk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ const uk = {
week: 'Тиждень',
month: 'Місяць',
season: 'Сезон',
allTime: 'Весь час',
commandCenter: 'Командний центр',
atGlance: 'Зведення',
financialOverview: 'Фінансовий огляд',
Expand Down
23 changes: 20 additions & 3 deletions frontend/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { useCallback, useEffect, useMemo } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { message } from 'antd';
import type { FieldDto } from '../types/field';
import type { AgroOperationDto } from '../types/operation';
Expand All @@ -14,6 +14,8 @@ import { KpiSkeleton, ChartSkeleton, TableSkeleton as TableSkeletonNew } from '.
import DashboardV2, { type DashboardPeriod } from './DashboardV2/DashboardV2';
import { periodToRange } from '../utils/periodToRange';

const VALID_PERIODS: DashboardPeriod[] = ['day', 'week', 'month', 'season'];

/**
* Real /dashboard route — thin container around the pure presentational
* {@link DashboardV2}. Handles role-based redirects, onboarding guard,
Expand All @@ -25,7 +27,22 @@ export default function Dashboard() {
const role = useAuthStore((s) => s.role);
const hasCompletedOnboarding = useAuthStore((s) => s.hasCompletedOnboarding);

const [period, setPeriod] = useState<DashboardPeriod>('season');
// Period is persisted in the URL so refresh/share/back-button all work.
// Invalid / missing values fall back to "season" (all-time).
const [searchParams, setSearchParams] = useSearchParams();
const rawPeriod = searchParams.get('period');
const period: DashboardPeriod = (VALID_PERIODS as string[]).includes(rawPeriod ?? '')
? (rawPeriod as DashboardPeriod)
: 'season';
const setPeriod = useCallback((p: DashboardPeriod) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (p === 'season') next.delete('period');
else next.set('period', p);
return next;
}, { replace: true });
}, [setSearchParams]);

const range = useMemo(() => periodToRange(period), [period]);

const { data, isLoading: dashLoading, isError: dashError } = useDashboardQuery(range);
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/pages/Dashboard/components/RevenueCostChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ interface Props {
title: string;
costLabel: string;
revenueLabel?: string;
/** Fires with the clicked point's `name` ("YYYY-MM"). Used for drill-down. */
onPointClick?: (name: string) => void;
}

interface TooltipEntry {
Expand Down Expand Up @@ -74,16 +76,26 @@ const CustomTooltip = ({ active, payload, label, profitLabel }: {
);
};

export default function RevenueCostChart({ data, title, costLabel, revenueLabel }: Props) {
export default function RevenueCostChart({ data, title, costLabel, revenueLabel, onPointClick }: Props) {
const { t } = useTranslation();
const profitLabel = (t.dashboard as Record<string, string | undefined>).profitDelta ?? 'Profit';
const handleClick = onPointClick
? (state: { activeLabel?: string } | null) => {
if (state?.activeLabel) onPointClick(state.activeLabel);
}
: undefined;
return (
<div className={s.card}>
<div className={s.header}>
<span className={s.title}>{title}</span>
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<AreaChart
data={data}
margin={{ top: 8, right: 12, left: 0, bottom: 4 }}
onClick={handleClick}
style={onPointClick ? { cursor: 'pointer' } : undefined}
>
<defs>
<linearGradient id="gradCost" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F59E0B" stopOpacity={0.28} />
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/pages/DashboardV2/DashboardV2.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,20 @@
/* Period segmented */
.headerRight {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
}

.rangeLabel {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
}

.segmented {
display: inline-flex;
padding: 3px;
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/pages/DashboardV2/DashboardV2.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Map, Banknote, Activity, TrendingUp } from 'lucide-react';
import type { DashboardDto } from '../../types/analytics';
Expand All @@ -7,6 +8,7 @@ import type { AgroOperationDto } from '../../types/operation';
import { useTranslation } from '../../i18n';
import { formatUA } from '../../utils/numberFormat';
import { computeTrend } from '../../utils/computeTrend';
import { formatPeriodRange } from '../../utils/formatPeriodRange';
import KpiHeroRow from '../Dashboard/components/KpiHeroRow';
import RevenueCostChart from '../Dashboard/components/RevenueCostChart';
import FieldStatusCard from '../Dashboard/components/FieldStatusCard';
Expand Down Expand Up @@ -48,15 +50,31 @@ const fadeIn = {
* the real /dashboard route by wrapping it in a hook-driven container.
*/
export default function DashboardV2({ data, fields, operations, weather, period: periodProp, onPeriodChange }: DashboardV2Props) {
const { t } = useTranslation();
const { t, lang } = useTranslation();
const dash = t.dashboard as Record<string, string | undefined>;
const navigate = useNavigate();
const [periodLocal, setPeriodLocal] = useState<Period>('season');
const period = periodProp ?? periodLocal;
const handlePeriodChange = useCallback((p: Period) => {
if (onPeriodChange) onPeriodChange(p);
else setPeriodLocal(p);
}, [onPeriodChange]);

/* Drill-down from the revenue/cost chart: each point's `name` is
"YYYY-MM" (see costTrendData below). Clicking a point jumps to the
Costs page pre-filtered to that month. */
const handleChartPointClick = useCallback((name: string) => {
const m = /^(\d{4})-(\d{2})$/.exec(name);
if (!m) return;
const year = Number(m[1]);
const month = Number(m[2]); // 1-12
const from = `${m[1]}-${m[2]}-01`;
// last day of the month in UTC
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
const to = `${m[1]}-${m[2]}-${String(lastDay).padStart(2, '0')}`;
navigate(`/economics/costs?from=${from}&to=${to}`);
}, [navigate]);

/* KPI label resolver — uses dedicated i18n key per period so the card title
reflects the selected range (day / week / month / season). */
const periodLabel = (kind: 'expenses' | 'revenue' | 'profit'): string => {
Expand Down Expand Up @@ -180,6 +198,9 @@ export default function DashboardV2({ data, fields, operations, weather, period:
</button>
))}
</div>
<div className={s.rangeLabel} aria-live="polite">
{formatPeriodRange(period, dash.allTime ?? 'Весь час', lang as 'uk' | 'en')}
</div>
</div>
</motion.header>

Expand Down Expand Up @@ -207,6 +228,7 @@ export default function DashboardV2({ data, fields, operations, weather, period:
title={t.dashboard.costTrend}
costLabel={t.dashboard.costsUAH}
revenueLabel={hasRevenueSeries ? dash.revenueLabel : undefined}
onPointClick={handleChartPointClick}
/>
</motion.section>
)}
Expand Down
27 changes: 23 additions & 4 deletions frontend/src/pages/Economics/CostRecords.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 { Table, Tag, Space, DatePicker, Select, message, Button, Modal, Form, Input, InputNumber } from 'antd';
import dayjs from 'dayjs';
import { PlusOutlined, ExperimentOutlined, AppstoreOutlined, MedicineBoxOutlined, ThunderboltOutlined, GiftOutlined, CalculatorOutlined, DownloadOutlined, HomeOutlined, PrinterOutlined } from '@ant-design/icons';
import { printReport } from '../../utils/printReport';
import { getCostRecords, getCostSummary, createCostRecord, deleteCostRecord } from '../../api/economics';
Expand Down Expand Up @@ -28,11 +30,19 @@ const categoryColors: Record<string, string> = {
};

export default function CostRecords() {
// Accept ?from=YYYY-MM-DD&to=YYYY-MM-DD for drill-down from the dashboard.
const [searchParams, setSearchParams] = useSearchParams();
const initialFrom = searchParams.get('from');
const initialTo = searchParams.get('to');
const isIsoDate = (v: string | null): v is string => !!v && /^\d{4}-\d{2}-\d{2}$/.test(v);
const initialRange: [string, string] | null =
isIsoDate(initialFrom) && isIsoDate(initialTo) ? [initialFrom, initialTo] : null;

const [result, setResult] = useState<PaginatedResult<CostRecordDto> | null>(null);
const [summary, setSummary] = useState<CostSummaryDto | null>(null);
const [loading, setLoading] = useState(true);
const [category, setCategory] = useState<CostCategory | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
const [dateRange, setDateRange] = useState<[string, string] | null>(initialRange);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [modalOpen, setModalOpen] = useState(false);
Expand Down Expand Up @@ -187,9 +197,18 @@ export default function CostRecords() {
options={Object.entries(t.costCategories).map(([k, v]) => ({ value: k, label: v }))}
/>
<RangePicker
onChange={(_, dateStrings) =>
setDateRange(dateStrings[0] && dateStrings[1] ? [dateStrings[0], dateStrings[1]] : null)
}
value={dateRange ? [dayjs(dateRange[0]), dayjs(dateRange[1])] : null}
onChange={(_, dateStrings) => {
const next: [string, string] | null = dateStrings[0] && dateStrings[1] ? [dateStrings[0], dateStrings[1]] : null;
setDateRange(next);
// Keep the URL in sync so the filter is shareable and survives refresh.
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 });
}}
placeholder={[t.economics.dateFrom, t.economics.dateTo]}
/>
{canCreate && (
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/utils/formatPeriodRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { DashboardPeriod } from '../pages/DashboardV2/DashboardV2';

/**
* Human-readable label for the date range the dashboard is currently filtered
* by. Mirrors {@link periodToRange} but formats the result for the UI instead
* of sending it to the API.
*
* - `day` → "23 квіт 2026"
* - `week` → "17 – 23 квіт 2026"
* - `month` → "квіт 2026"
* - `season` → locale label for "all time"
*/
export function formatPeriodRange(
period: DashboardPeriod,
allTimeLabel: string,
locale: 'uk' | 'en' = 'uk',
reference: Date = new Date(),
): string {
const now = new Date(reference.getTime());
const lang = locale === 'uk' ? 'uk-UA' : 'en-US';

const fmtDay = (d: Date) => d.toLocaleDateString(lang, { day: 'numeric', month: 'short', year: 'numeric' });
const fmtDayShort = (d: Date) => d.toLocaleDateString(lang, { day: 'numeric', month: 'short' });
const fmtMonth = (d: Date) => d.toLocaleDateString(lang, { month: 'short', year: 'numeric' });

switch (period) {
case 'day':
return fmtDay(now);
case 'week': {
const weekAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
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 Match week label to the actual 7-day query window

The new range label for period='week' is off by one day compared to the backend filter: formatPeriodRange subtracts 6 days, while periodToRange('week') subtracts 7 days before querying analytics. In practice, a dashboard filtered with the current week request can include data from an extra calendar day (e.g., Apr 16), but the UI label will display Apr 17 – Apr 23, which misstates the range users are interpreting.

Useful? React with 👍 / 👎.

return `${fmtDayShort(weekAgo)} – ${fmtDay(now)}`;
}
case 'month':
return fmtMonth(now);
case 'season':
default:
return allTimeLabel;
}
}
Loading