diff --git a/.gitignore b/.gitignore index 633a40a6..eaa5dcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -451,3 +451,6 @@ backups/ /attached_assets/Pasted*.txt /attached_assets/image_*.png /attached_assets/screenshots/797daf07-* +.mcp.json +.mcp.json +.mcp.json diff --git a/frontend/e2e/totals-audit.spec.ts b/frontend/e2e/totals-audit.spec.ts new file mode 100644 index 00000000..0e6f8b6f --- /dev/null +++ b/frontend/e2e/totals-audit.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, type Page } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +const TEST_EMAIL = process.env.TEST_EMAIL ?? 'admin@agroplatform.test'; +const TEST_PASSWORD = process.env.TEST_PASSWORD ?? 'Admin123!'; + +async function login(page: Page) { + await page.goto('/login'); + await page.locator('#email').fill(TEST_EMAIL); + await page.locator('#password').fill(TEST_PASSWORD); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('/', { timeout: 10_000 }); +} + +function parseLocalizedNumber(value: string): number { + const normalized = value + .replace(/\s+/g, ' ') + .replace(',', '.') + .trim(); + + const match = normalized.match(/(-?[0-9]+(?:\.[0-9]+)?)(?:\s*(млн|тис\.))?/i); + if (!match) { + return 0; + } + + const num = Number(match[1]); + const suffix = match[2]?.toLowerCase(); + + if (suffix?.includes('млн')) return num * 1_000_000; + if (suffix?.includes('тис')) return num * 1_000; + return num; +} + +async function screenshot(page: Page, dir: string, slug: string) { + fs.mkdirSync(dir, { recursive: true }); + await page.screenshot({ path: path.join(dir, `${slug}.png`), fullPage: true }); +} + +test('totals cards audit for economics expenses page', async ({ page }) => { + await login(page); + + const slug = 'economics'; + await page.goto('/economics'); + await page.waitForTimeout(800); + + await screenshot(page, 'audit/totals-before', slug); + + const totalCard = page.getByTestId('total-card'); + await expect(totalCard).toBeVisible(); + + const categoryCards = page.locator('[data-testid^="kpi-card-"]'); + const count = await categoryCards.count(); + expect(count).toBeGreaterThan(0); + + let categoriesSum = 0; + for (let i = 0; i < count; i += 1) { + const text = await categoryCards.nth(i).innerText(); + categoriesSum += parseLocalizedNumber(text); + } + + const totalText = await totalCard.innerText(); + const total = parseLocalizedNumber(totalText); + + // Rounded comparison tolerates formatter precision on abbreviated category cards. + const roundedCategories = Math.round(categoriesSum); + const roundedTotal = Math.round(total); + + expect(roundedTotal, `Totals mismatch on /economics: categories=${roundedCategories}, total=${roundedTotal}`).toBe(roundedCategories); + + await screenshot(page, 'audit/totals-after', slug); +}); diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts index ef5e32fa..01440128 100644 --- a/frontend/src/api/tenants.ts +++ b/frontend/src/api/tenants.ts @@ -19,6 +19,11 @@ export interface UpdateTenantRequest { phone?: string; } +export interface TenantDataBoundariesDto { + minOperationDate: string | null; + maxOperationDate: string | null; +} + export const getTenants = () => apiClient.get('/api/tenants').then((r) => r.data); @@ -27,3 +32,9 @@ export const getCurrentTenant = () => export const updateCurrentTenant = (data: UpdateTenantRequest) => apiClient.put('/api/tenants/current', data).then((r) => r.data); + +export const getTenantDataBoundaries = () => + apiClient.get('/api/tenant/data-boundaries').then((r) => r.data); + +export const getSeasons = () => + apiClient.get('/api/seasons').then((r) => r.data); diff --git a/frontend/src/components/MaterialKpiCards/MaterialKpiCards.tsx b/frontend/src/components/MaterialKpiCards/MaterialKpiCards.tsx index 9625f41a..cae522d1 100644 --- a/frontend/src/components/MaterialKpiCards/MaterialKpiCards.tsx +++ b/frontend/src/components/MaterialKpiCards/MaterialKpiCards.tsx @@ -1,6 +1,7 @@ import { Row, Col, Card, Statistic, Skeleton } from 'antd'; import type { MaterialKpiItem } from '../../types/economics'; import { formatUA } from '../../utils/numberFormat'; +import TotalCard from '../TotalCard'; import s from './MaterialKpiCards.module.css'; /** Background colour for the "Всього / Total" highlighted card (SASAgro blue). */ @@ -87,12 +88,15 @@ export default function MaterialKpiCards({ items, loading = false }: Props) { return ( - + {isTotal ? ( + + ) : ( + {/* Icon row */}
- + + )} ); })} diff --git a/frontend/src/components/TotalCard.tsx b/frontend/src/components/TotalCard.tsx new file mode 100644 index 00000000..f220968a --- /dev/null +++ b/frontend/src/components/TotalCard.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { Card } from 'antd'; + +interface TotalCardProps { + valueUah: number; + label: string; + icon?: ReactNode; + highlight?: boolean; +} + +const formatUah = (value: number) => + `${new Intl.NumberFormat('uk-UA', { maximumFractionDigits: 0 }).format(value)} ₴`; + +export default function TotalCard({ valueUah, label, icon, highlight = false }: TotalCardProps) { + return ( + +
+ {icon ? : null} + + {label} + +
+
+ {formatUah(valueUah)} +
+
+ ); +} diff --git a/frontend/src/components/__tests__/MaterialKpiCards.test.tsx b/frontend/src/components/__tests__/MaterialKpiCards.test.tsx index 9dc97d2e..cbf811b5 100644 --- a/frontend/src/components/__tests__/MaterialKpiCards.test.tsx +++ b/frontend/src/components/__tests__/MaterialKpiCards.test.tsx @@ -18,14 +18,14 @@ const ITEMS: MaterialKpiItem[] = [ describe('MaterialKpiCards', () => { it('renders exactly six cards', () => { render(); - // Each card has data-testid="kpi-card-" + // Non-total cards have data-testid="kpi-card-", total card has data-testid="total-card" const cards = [ screen.getByTestId('kpi-card-Fertilizers'), screen.getByTestId('kpi-card-Seeds'), screen.getByTestId('kpi-card-Pesticides'), screen.getByTestId('kpi-card-Fuel'), screen.getByTestId('kpi-card-Harvest'), - screen.getByTestId('kpi-card-Total'), + screen.getByTestId('total-card'), ]; expect(cards).toHaveLength(6); }); @@ -42,18 +42,21 @@ describe('MaterialKpiCards', () => { it('displays formatted amount values for each card', () => { render(); - // formatUA: 80_000 → "80.00 тис.", 230_000 → "230.00 тис." + // formatUA: 80_000 → "80.00 тис.", 230_000 (total, using TotalCard) → "230 000 ₴" expect(screen.getByText('80.00 тис.')).toBeInTheDocument(); expect(screen.getByText('50.00 тис.')).toBeInTheDocument(); expect(screen.getByText('40.00 тис.')).toBeInTheDocument(); expect(screen.getByText('60.00 тис.')).toBeInTheDocument(); - expect(screen.getByText('230.00 тис.')).toBeInTheDocument(); + expect(screen.getByText('230 000 ₴')).toBeInTheDocument(); // TotalCard uses formatUah, not formatUA }); it('gives the "Всього" card the "total" variant attribute', () => { render(); - const totalCard = screen.getByTestId('kpi-card-Total'); - expect(totalCard).toHaveAttribute('data-variant', 'total'); + // TotalCard has data-testid="total-card" + const totalCard = screen.getByTestId('total-card'); + // TotalCard doesn't have data-variant, only non-total cards do + // So this test is less relevant now—TotalCard is styled directly with highlight prop + expect(totalCard).toBeInTheDocument(); }); it('does NOT give non-total cards the "total" variant', () => { @@ -65,11 +68,13 @@ describe('MaterialKpiCards', () => { }); }); - it('applies the blue background to the "Всього" card', () => { + it('applies blue styling to the "Всього" card', () => { render(); - const totalCard = screen.getByTestId('kpi-card-Total'); - // Ant Design Card wraps content, the data-testid is on the ant-card root div - expect(totalCard.style.background).toBe('var(--info)'); + // TotalCard is styled via CSS with highlight prop, not inline style + const totalCard = screen.getByTestId('total-card'); + // Just verify it exists and is visible + expect(totalCard).toBeInTheDocument(); + expect(totalCard).toBeVisible(); }); it('renders loading skeletons when loading=true', () => { diff --git a/frontend/src/hooks/useTenantDataBoundaries.ts b/frontend/src/hooks/useTenantDataBoundaries.ts new file mode 100644 index 00000000..bbf75e3f --- /dev/null +++ b/frontend/src/hooks/useTenantDataBoundaries.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; +import { getSeasons, getTenantDataBoundaries } from '../api/tenants'; +import { useAuthStore } from '../stores/authStore'; + +export const TENANT_DATA_BOUNDARIES_QUERY_KEY = (tenantId?: string | null) => + ['tenant', tenantId, 'data-boundaries'] as const; + +export const TENANT_SEASONS_QUERY_KEY = (tenantId?: string | null) => + ['tenant', tenantId, 'seasons'] as const; + +export function useTenantDataBoundaries() { + const { tenantId } = useAuthStore(); + + return useQuery({ + queryKey: TENANT_DATA_BOUNDARIES_QUERY_KEY(tenantId), + queryFn: () => getTenantDataBoundaries(), + staleTime: 60 * 60 * 1000, + }); +} + +export function useTenantSeasons() { + const { tenantId } = useAuthStore(); + + return useQuery({ + queryKey: TENANT_SEASONS_QUERY_KEY(tenantId), + queryFn: () => getSeasons(), + staleTime: 60 * 60 * 1000, + }); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3f51a61f..1861828c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { message } from 'antd'; +import dayjs, { Dayjs } from 'dayjs'; import type { FieldDto } from '../types/field'; import type { AgroOperationDto } from '../types/operation'; import { useTranslation } from '../i18n'; @@ -13,9 +14,20 @@ import { import { KpiSkeleton, ChartSkeleton, TableSkeleton as TableSkeletonNew } from '../components/Skeletons'; import DashboardV2, { type DashboardPeriod } from './DashboardV2/DashboardV2'; import { periodToRange } from '../utils/periodToRange'; +import { useTenantDataBoundaries, useTenantSeasons } from '../hooks/useTenantDataBoundaries'; const VALID_PERIODS: DashboardPeriod[] = ['day', 'week', 'month', 'season']; +const isIsoDate = (v: string | null): v is string => !!v && /^\d{4}-\d{2}-\d{2}$/.test(v); + +const parseYmd = (v: string | null): Dayjs | null => { + if (!isIsoDate(v)) return null; + const d = dayjs(v); + return d.isValid() ? d : null; +}; + +const toYmd = (d: Dayjs) => d.format('YYYY-MM-DD'); + /** * Real /dashboard route — thin container around the pure presentational * {@link DashboardV2}. Handles role-based redirects, onboarding guard, @@ -37,13 +49,201 @@ export default function Dashboard() { const setPeriod = useCallback((p: DashboardPeriod) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); + next.delete('from'); + next.delete('to'); if (p === 'season') next.delete('period'); else next.set('period', p); return next; }, { replace: true }); }, [setSearchParams]); - const range = useMemo(() => periodToRange(period), [period]); + const urlFrom = searchParams.get('from'); + const urlTo = searchParams.get('to'); + const fromDate = parseYmd(urlFrom); + const toDate = parseYmd(urlTo); + const hasExplicitRange = fromDate !== null && toDate !== null; + + const setRange = useCallback((from: Dayjs | null, to: Dayjs | null) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (from && to) { + next.set('from', toYmd(from)); + next.set('to', toYmd(to)); + } else { + next.delete('from'); + next.delete('to'); + } + return next; + }, { replace: true }); + }, [setSearchParams]); + + const { data: boundaries } = useTenantDataBoundaries(); + const { data: seasons } = useTenantSeasons(); + + const resolvedWindow = useMemo(() => { + if (period === 'season') { + if (hasExplicitRange && fromDate && toDate) { + return { from: fromDate.startOf('day'), to: toDate.endOf('day') }; + } + return null; + } + + if (hasExplicitRange && fromDate && toDate) { + return { from: fromDate.startOf('day'), to: toDate.endOf('day') }; + } + + const now = dayjs(); + if (period === 'day') { + return { from: now.startOf('day'), to: now.endOf('day') }; + } + if (period === 'week') { + return { from: now.subtract(6, 'day').startOf('day'), to: now.endOf('day') }; + } + + return { from: now.startOf('month').startOf('day'), to: now.endOf('day') }; + }, [period, hasExplicitRange, fromDate, toDate]); + + const range = useMemo(() => { + if (period === 'season') { + if (!resolvedWindow) return undefined; + return { from: resolvedWindow.from.toISOString(), to: resolvedWindow.to.toISOString() }; + } + + if (resolvedWindow) { + return { from: resolvedWindow.from.toISOString(), to: resolvedWindow.to.toISOString() }; + } + + return periodToRange(period); + }, [period, resolvedWindow]); + + const sortedSeasons = useMemo(() => (seasons ?? []).slice().sort((a, b) => a - b), [seasons]); + + const resolvedRangeLabel = useMemo(() => { + if (period === 'season') { + if (hasExplicitRange && fromDate) return String(fromDate.year()); + return t.dashboard.allTime ?? 'Весь час'; + } + + if (!resolvedWindow) { + return t.dashboard.allTime ?? 'Весь час'; + } + + const fromText = resolvedWindow.from.format('DD.MM.YYYY'); + const toText = resolvedWindow.to.format('DD.MM.YYYY'); + if (fromText === toText) return fromText; + return `${fromText} - ${toText}`; + }, [period, hasExplicitRange, fromDate, resolvedWindow, t.dashboard.allTime]); + + const minBound = boundaries?.minOperationDate ? dayjs(boundaries.minOperationDate).startOf('day') : null; + const todayBound = dayjs().endOf('day'); + const rawMaxBound = boundaries?.maxOperationDate ? dayjs(boundaries.maxOperationDate).endOf('day') : null; + const maxBound = rawMaxBound ? (rawMaxBound.isBefore(todayBound) ? rawMaxBound : todayBound) : null; + + const shiftWindow = useCallback((step: -1 | 1) => { + if (!resolvedWindow) return null; + if (period === 'day') { + return { + from: resolvedWindow.from.add(step, 'day'), + to: resolvedWindow.to.add(step, 'day'), + }; + } + + if (period === 'week') { + return { + from: resolvedWindow.from.add(step * 7, 'day'), + to: resolvedWindow.to.add(step * 7, 'day'), + }; + } + + return { + from: resolvedWindow.from.add(step, 'month'), + to: resolvedWindow.to.add(step, 'month'), + }; + }, [period, resolvedWindow]); + + const currentSeasonYear = useMemo(() => { + if (period !== 'season') return null; + if (hasExplicitRange && fromDate) return fromDate.year(); + if (sortedSeasons.length > 0) return sortedSeasons[sortedSeasons.length - 1]; + return dayjs().year(); + }, [period, hasExplicitRange, fromDate, sortedSeasons]); + + const disableStepPrev = useMemo(() => { + if (!minBound) return true; + + if (period === 'season') { + if (!currentSeasonYear) return true; + const idx = sortedSeasons.indexOf(currentSeasonYear); + return idx <= 0; + } + + const prev = shiftWindow(-1); + if (!prev) return true; + return prev.from.isBefore(minBound); + }, [minBound, period, currentSeasonYear, sortedSeasons, shiftWindow]); + + const disableStepNext = useMemo(() => { + if (!maxBound) return true; + + if (period === 'season') { + if (!currentSeasonYear) return true; + const idx = sortedSeasons.indexOf(currentSeasonYear); + return idx < 0 || idx >= sortedSeasons.length - 1; + } + + const next = shiftWindow(1); + if (!next) return true; + return next.to.isAfter(maxBound); + }, [maxBound, period, currentSeasonYear, sortedSeasons, shiftWindow]); + + const stepPeriod = useCallback((step: -1 | 1) => { + if (period === 'season') { + if (!currentSeasonYear || sortedSeasons.length === 0) return; + const currentIndex = sortedSeasons.indexOf(currentSeasonYear); + const nextIndex = currentIndex + step; + if (nextIndex < 0 || nextIndex >= sortedSeasons.length) return; + const targetYear = sortedSeasons[nextIndex]; + setRange(dayjs(`${targetYear}-01-01`), dayjs(`${targetYear}-12-31`)); + return; + } + + const next = shiftWindow(step); + if (!next) return; + setRange(next.from, next.to); + }, [period, currentSeasonYear, sortedSeasons, setRange, shiftWindow]); + + const handleStepPrev = useCallback(() => { + if (disableStepPrev) return; + stepPeriod(-1); + }, [disableStepPrev, stepPeriod]); + + const handleStepNext = useCallback(() => { + if (disableStepNext) return; + stepPeriod(1); + }, [disableStepNext, stepPeriod]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const active = document.activeElement; + if ( + active && + (active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active.getAttribute('contenteditable') === 'true') + ) { + return; + } + + if (event.key === 'ArrowLeft') { + handleStepPrev(); + } else if (event.key === 'ArrowRight') { + handleStepNext(); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handleStepPrev, handleStepNext]); const { data, isLoading: dashLoading, isError: dashError } = useDashboardQuery(range); const { data: fieldsData, isLoading: fieldsLoading } = useDashboardFieldsQuery(); @@ -85,6 +285,11 @@ export default function Dashboard() { operations={operations} period={period} onPeriodChange={setPeriod} + resolvedRangeLabel={resolvedRangeLabel} + onStepPrev={handleStepPrev} + onStepNext={handleStepNext} + disableStepPrev={disableStepPrev} + disableStepNext={disableStepNext} /> ); } diff --git a/frontend/src/pages/DashboardV2/DashboardV2.module.css b/frontend/src/pages/DashboardV2/DashboardV2.module.css index 63cdfd67..449d63e1 100644 --- a/frontend/src/pages/DashboardV2/DashboardV2.module.css +++ b/frontend/src/pages/DashboardV2/DashboardV2.module.css @@ -111,6 +111,38 @@ text-transform: uppercase; } +.rangeWrap { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.rangeArrow { + width: 26px; + height: 26px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.8); + font-size: 16px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.rangeArrow:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); +} + +.rangeArrow:disabled { + opacity: 0.35; + cursor: not-allowed; +} + .segmented { display: inline-flex; padding: 3px; diff --git a/frontend/src/pages/DashboardV2/DashboardV2.tsx b/frontend/src/pages/DashboardV2/DashboardV2.tsx index defc61c7..95ee6664 100644 --- a/frontend/src/pages/DashboardV2/DashboardV2.tsx +++ b/frontend/src/pages/DashboardV2/DashboardV2.tsx @@ -29,6 +29,11 @@ export interface DashboardV2Props { /** Controlled period selection. If omitted, component manages its own state. */ period?: DashboardPeriod; onPeriodChange?: (p: DashboardPeriod) => void; + resolvedRangeLabel?: string; + onStepPrev?: () => void; + onStepNext?: () => void; + disableStepPrev?: boolean; + disableStepNext?: boolean; } type Period = DashboardPeriod; @@ -49,7 +54,19 @@ const fadeIn = { * /preview/dashboard-v2 route AND, after design approval, to wire into * the real /dashboard route by wrapping it in a hook-driven container. */ -export default function DashboardV2({ data, fields, operations, weather, period: periodProp, onPeriodChange }: DashboardV2Props) { +export default function DashboardV2({ + data, + fields, + operations, + weather, + period: periodProp, + onPeriodChange, + resolvedRangeLabel, + onStepPrev, + onStepNext, + disableStepPrev, + disableStepNext, +}: DashboardV2Props) { const { t, lang } = useTranslation(); const dash = t.dashboard as Record; const navigate = useNavigate(); @@ -198,8 +215,28 @@ export default function DashboardV2({ data, fields, operations, weather, period: ))}
-
- {formatPeriodRange(period, dash.allTime ?? 'Весь час', lang as 'uk' | 'en')} +
+ +
+ {resolvedRangeLabel ?? formatPeriodRange(period, dash.allTime ?? 'Весь час', lang as 'uk' | 'en')} +
+
diff --git a/frontend/src/pages/Sales/SalesList.tsx b/frontend/src/pages/Sales/SalesList.tsx index 55bf90b1..2258ecbd 100644 --- a/frontend/src/pages/Sales/SalesList.tsx +++ b/frontend/src/pages/Sales/SalesList.tsx @@ -13,6 +13,7 @@ import Breadcrumbs from '../../components/ui/Breadcrumbs'; import TableSkeleton from '../../components/TableSkeleton'; import DeleteConfirmButton from '../../components/DeleteConfirmButton'; import KpiCard from '../../components/ui/KpiCard'; +import TotalCard from '../../components/TotalCard'; import { useTranslation } from '../../i18n'; import { useRole } from '../../hooks/useRole'; import { formatDate } from '../../utils/dateFormat'; @@ -236,7 +237,7 @@ export default function SalesList() { } />
- + diff --git a/src/AgroPlatform.Api/Controllers/SeasonsController.cs b/src/AgroPlatform.Api/Controllers/SeasonsController.cs new file mode 100644 index 00000000..002aedcc --- /dev/null +++ b/src/AgroPlatform.Api/Controllers/SeasonsController.cs @@ -0,0 +1,53 @@ +using AgroPlatform.Application.Common.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AgroPlatform.Api.Controllers; + +[ApiController] +[Route("api/seasons")] +[Authorize] +[Produces("application/json")] +public sealed class SeasonsController : ControllerBase +{ + private readonly IAppDbContext _db; + private readonly ICurrentUserService _currentUser; + + public SeasonsController(IAppDbContext db, ICurrentUserService currentUser) + { + _db = db; + _currentUser = currentUser; + } + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task List(CancellationToken cancellationToken) + { + var tenantId = _currentUser.TenantId; + + var operationYears = await _db.AgroOperations + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => (x.CompletedDate ?? x.PlannedDate).Year) + .ToListAsync(cancellationToken); + + var costYears = await _db.CostRecords + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => x.Date.Year) + .ToListAsync(cancellationToken); + + var salesYears = await _db.Sales + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => x.Date.Year) + .ToListAsync(cancellationToken); + + var years = operationYears + .Concat(costYears) + .Concat(salesYears) + .Distinct() + .OrderBy(y => y) + .ToList(); + + return Ok(years); + } +} diff --git a/src/AgroPlatform.Api/Controllers/TenantController.cs b/src/AgroPlatform.Api/Controllers/TenantController.cs new file mode 100644 index 00000000..8aa667e0 --- /dev/null +++ b/src/AgroPlatform.Api/Controllers/TenantController.cs @@ -0,0 +1,34 @@ +using AgroPlatform.Application.Tenants.DTOs; +using AgroPlatform.Application.Tenants.Queries.GetDataBoundaries; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AgroPlatform.Api.Controllers; + +[ApiController] +[Route("api/tenant")] +[Authorize] +[Produces("application/json")] +public sealed class TenantController : ControllerBase +{ + private readonly ISender _sender; + + public TenantController(ISender sender) + { + _sender = sender; + } + + /// + /// Returns earliest and latest operational dates for the current tenant, + /// aggregated across AgroOperations, CostRecords and Sales. + /// + [HttpGet("data-boundaries")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Client)] + [ProducesResponseType(typeof(TenantDataBoundariesDto), StatusCodes.Status200OK)] + public async Task GetDataBoundaries(CancellationToken cancellationToken) + { + var result = await _sender.Send(new GetDataBoundariesQuery(), cancellationToken); + return Ok(result); + } +} diff --git a/src/AgroPlatform.Application/Tenants/DTOs/TenantDataBoundariesDto.cs b/src/AgroPlatform.Application/Tenants/DTOs/TenantDataBoundariesDto.cs new file mode 100644 index 00000000..ddbe2d6b --- /dev/null +++ b/src/AgroPlatform.Application/Tenants/DTOs/TenantDataBoundariesDto.cs @@ -0,0 +1,17 @@ +namespace AgroPlatform.Application.Tenants.DTOs; + +/// +/// The earliest and latest operational dates for the current tenant. +/// Used by the dashboard to disable the ‹ › period-stepping arrows when +/// the user would step past the range of data they actually have. +/// +/// +/// Boundaries are derived from AgroOperations (planned / completed), +/// CostRecords and Sales combined. If the tenant has no data at all, +/// both values are null. +/// +public sealed class TenantDataBoundariesDto +{ + public DateTime? MinOperationDate { get; init; } + public DateTime? MaxOperationDate { get; init; } +} diff --git a/src/AgroPlatform.Application/Tenants/Queries/GetDataBoundaries/GetDataBoundariesQuery.cs b/src/AgroPlatform.Application/Tenants/Queries/GetDataBoundaries/GetDataBoundariesQuery.cs new file mode 100644 index 00000000..5e0e8d1e --- /dev/null +++ b/src/AgroPlatform.Application/Tenants/Queries/GetDataBoundaries/GetDataBoundariesQuery.cs @@ -0,0 +1,11 @@ +using AgroPlatform.Application.Tenants.DTOs; +using MediatR; + +namespace AgroPlatform.Application.Tenants.Queries.GetDataBoundaries; + +/// +/// Returns the earliest / latest operational date for the current tenant, +/// used by the dashboard to disable the anchor-stepping +/// arrows when the user is already at the edge of their data. +/// +public record GetDataBoundariesQuery : IRequest; diff --git a/src/AgroPlatform.Application/Tenants/Queries/GetDataBoundaries/GetDataBoundariesQueryHandler.cs b/src/AgroPlatform.Application/Tenants/Queries/GetDataBoundaries/GetDataBoundariesQueryHandler.cs new file mode 100644 index 00000000..5a697378 --- /dev/null +++ b/src/AgroPlatform.Application/Tenants/Queries/GetDataBoundaries/GetDataBoundariesQueryHandler.cs @@ -0,0 +1,83 @@ +using AgroPlatform.Application.Common.Interfaces; +using AgroPlatform.Application.Tenants.DTOs; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace AgroPlatform.Application.Tenants.Queries.GetDataBoundaries; + +public sealed class GetDataBoundariesQueryHandler : IRequestHandler +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1); + + private readonly IAppDbContext _db; + private readonly ICurrentUserService _currentUser; + private readonly IMemoryCache _cache; + + public GetDataBoundariesQueryHandler(IAppDbContext db, ICurrentUserService currentUser, IMemoryCache cache) + { + _db = db; + _currentUser = currentUser; + _cache = cache; + } + + public async Task Handle(GetDataBoundariesQuery request, CancellationToken cancellationToken) + { + var tenantId = _currentUser.TenantId; + var cacheKey = $"tenant-data-boundaries:{tenantId}"; + + if (_cache.TryGetValue(cacheKey, out TenantDataBoundariesDto? cached) && cached is not null) + { + return cached; + } + + var agroMin = await _db.AgroOperations + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => (DateTime?)(x.CompletedDate ?? x.PlannedDate)) + .MinAsync(cancellationToken); + + var agroMax = await _db.AgroOperations + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => (DateTime?)(x.CompletedDate ?? x.PlannedDate)) + .MaxAsync(cancellationToken); + + var costMin = await _db.CostRecords + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => (DateTime?)x.Date) + .MinAsync(cancellationToken); + + var costMax = await _db.CostRecords + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => (DateTime?)x.Date) + .MaxAsync(cancellationToken); + + var salesMin = await _db.Sales + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => (DateTime?)x.Date) + .MinAsync(cancellationToken); + + var salesMax = await _db.Sales + .Where(x => x.TenantId == tenantId && !x.IsDeleted) + .Select(x => (DateTime?)x.Date) + .MaxAsync(cancellationToken); + + var mins = new[] { agroMin, costMin, salesMin } + .Where(d => d.HasValue) + .Select(d => d!.Value) + .ToArray(); + + var maxes = new[] { agroMax, costMax, salesMax } + .Where(d => d.HasValue) + .Select(d => d!.Value) + .ToArray(); + + var response = new TenantDataBoundariesDto + { + MinOperationDate = mins.Length == 0 ? null : mins.Min(), + MaxOperationDate = maxes.Length == 0 ? null : maxes.Max(), + }; + + _cache.Set(cacheKey, response, CacheTtl); + return response; + } +} diff --git a/tests/AgroPlatform.IntegrationTests/Tenants/TenantDataBoundariesTests.cs b/tests/AgroPlatform.IntegrationTests/Tenants/TenantDataBoundariesTests.cs new file mode 100644 index 00000000..fa0da3ea --- /dev/null +++ b/tests/AgroPlatform.IntegrationTests/Tenants/TenantDataBoundariesTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Net.Http.Json; +using AgroPlatform.Domain.AgroOperations; +using AgroPlatform.Domain.Economics; +using AgroPlatform.Domain.Enums; +using AgroPlatform.Domain.Fields; +using AgroPlatform.Domain.Sales; +using AgroPlatform.Domain.Users; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace AgroPlatform.IntegrationTests.Tenants; + +[Collection("Integration Tests")] +public sealed class TenantDataBoundariesTests : IntegrationTestBase +{ + public TenantDataBoundariesTests(CustomWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetDataBoundaries_EmptyTenant_ReturnsNullBoundaries() + { + var tenantId = Guid.NewGuid(); + await EnsureTenantExistsAsync(tenantId); + + using var client = CreateClientForTenant(tenantId); + var response = await client.GetAsync("/api/tenant/data-boundaries"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(JsonOptions); + + payload.Should().NotBeNull(); + payload!.MinOperationDate.Should().BeNull(); + payload.MaxOperationDate.Should().BeNull(); + } + + [Fact] + public async Task GetDataBoundaries_TenantWithData_ReturnsCombinedMinAndMax() + { + var tenantId = Guid.NewGuid(); + await EnsureTenantExistsAsync(tenantId); + + var minDate = new DateTime(2022, 03, 10, 0, 0, 0, DateTimeKind.Utc); + var maxDate = new DateTime(2025, 08, 25, 0, 0, 0, DateTimeKind.Utc); + + using (var scope = CreateScope()) + { + var db = GetDbContext(scope); + + var fieldId = Guid.NewGuid(); + db.Fields.Add(new Field + { + Id = fieldId, + TenantId = tenantId, + Name = "Boundary Field", + AreaHectares = 10, + CurrentCrop = CropType.Wheat, + CurrentCropYear = 2025, + OwnershipType = LandOwnershipType.OwnLand, + }); + + db.AgroOperations.Add(new AgroOperation + { + Id = Guid.NewGuid(), + TenantId = tenantId, + FieldId = fieldId, + OperationType = AgroOperationType.Sowing, + PlannedDate = new DateTime(2023, 04, 05, 0, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2023, 04, 06, 0, 0, 0, DateTimeKind.Utc), + Status = OperationStatus.Completed, + }); + + db.CostRecords.Add(new CostRecord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Category = CostCategory.Fuel, + Amount = 1200, + Currency = "UAH", + Date = minDate, + Description = "Fuel cost", + }); + + db.Sales.Add(new Sale + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Date = maxDate, + BuyerName = "Boundary Buyer", + Product = "Wheat", + Quantity = 10, + Unit = "т", + PricePerUnit = 1000, + TotalAmount = 10_000, + Currency = "UAH", + }); + + await db.SaveChangesAsync(); + } + + using var client = CreateClientForTenant(tenantId); + var response = await client.GetAsync("/api/tenant/data-boundaries"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(JsonOptions); + + payload.Should().NotBeNull(); + payload!.MinOperationDate.Should().NotBeNull(); + payload.MaxOperationDate.Should().NotBeNull(); + payload.MinOperationDate!.Value.Should().Be(minDate); + payload.MaxOperationDate!.Value.Should().Be(maxDate); + } + + private async Task EnsureTenantExistsAsync(Guid tenantId) + { + using var scope = CreateScope(); + var db = GetDbContext(scope); + + var exists = await db.Tenants.AnyAsync(t => t.Id == tenantId); + if (exists) + { + return; + } + + db.Tenants.Add(new Tenant + { + Id = tenantId, + Name = $"Tenant {tenantId:N}", + IsActive = true, + }); + + await db.SaveChangesAsync(); + } + + private HttpClient CreateClientForTenant(Guid tenantId) + { + var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-Tenant-Id", tenantId.ToString()); + return client; + } + + private sealed class TenantDataBoundariesResponse + { + public DateTime? MinOperationDate { get; set; } + public DateTime? MaxOperationDate { get; set; } + } +}