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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,6 @@ backups/
/attached_assets/Pasted*.txt
/attached_assets/image_*.png
/attached_assets/screenshots/797daf07-*
.mcp.json
.mcp.json
.mcp.json
72 changes: 72 additions & 0 deletions frontend/e2e/totals-audit.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
11 changes: 11 additions & 0 deletions frontend/src/api/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export interface UpdateTenantRequest {
phone?: string;
}

export interface TenantDataBoundariesDto {
minOperationDate: string | null;
maxOperationDate: string | null;
}

export const getTenants = () =>
apiClient.get<TenantDto[]>('/api/tenants').then((r) => r.data);

Expand All @@ -27,3 +32,9 @@ export const getCurrentTenant = () =>

export const updateCurrentTenant = (data: UpdateTenantRequest) =>
apiClient.put<TenantDto>('/api/tenants/current', data).then((r) => r.data);

export const getTenantDataBoundaries = () =>
apiClient.get<TenantDataBoundariesDto>('/api/tenant/data-boundaries').then((r) => r.data);

export const getSeasons = () =>
apiClient.get<number[]>('/api/seasons').then((r) => r.data);
19 changes: 12 additions & 7 deletions frontend/src/components/MaterialKpiCards/MaterialKpiCards.tsx
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -87,12 +88,15 @@ export default function MaterialKpiCards({ items, loading = false }: Props) {

return (
<Col key={item.key} xs={24} sm={12} md={8} lg={4}>
<Card
data-testid={`kpi-card-${item.key}`}
data-variant={isTotal ? 'total' : 'default'}
style={cardStyle}
styles={{ body: { padding: '16px 20px' } }}
>
{isTotal ? (
<TotalCard valueUah={item.amount} label={item.label} icon={item.icon} highlight />
) : (
<Card
data-testid={`kpi-card-${item.key}`}
data-variant="default"
style={cardStyle}
styles={{ body: { padding: '16px 20px' } }}
>
{/* Icon row */}
<div
className={s.flex_center}
Expand All @@ -112,7 +116,8 @@ export default function MaterialKpiCards({ items, loading = false }: Props) {
valueStyle={valueStyle}
className={s.spaced}
/>
</Card>
</Card>
)}
</Col>
);
})}
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/components/TotalCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card
data-testid="total-card"
style={{
background: highlight ? 'var(--info)' : 'var(--bg-surface)',
border: highlight ? '1px solid var(--info)' : '1px solid var(--border)',
boxShadow: highlight ? '0 4px 16px rgba(31,111,235,0.35)' : 'none',
}}
styles={{ body: { padding: '16px 20px' } }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{icon ? <span aria-hidden="true">{icon}</span> : null}
<span
style={{
color: highlight ? 'rgba(255,255,255,0.9)' : 'var(--text-secondary)',
fontSize: 12,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.4px',
}}
>
{label}
</span>
</div>
<div
style={{
color: highlight ? 'var(--bg-surface)' : 'var(--text-primary)',
fontSize: 22,
fontWeight: 700,
}}
>
{formatUah(valueUah)}
</div>
</Card>
);
}
25 changes: 15 additions & 10 deletions frontend/src/components/__tests__/MaterialKpiCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ const ITEMS: MaterialKpiItem[] = [
describe('MaterialKpiCards', () => {
it('renders exactly six cards', () => {
render(<MaterialKpiCards items={ITEMS} />);
// Each card has data-testid="kpi-card-<key>"
// Non-total cards have data-testid="kpi-card-<key>", 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);
});
Expand All @@ -42,18 +42,21 @@ describe('MaterialKpiCards', () => {

it('displays formatted amount values for each card', () => {
render(<MaterialKpiCards items={ITEMS} />);
// 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(<MaterialKpiCards items={ITEMS} />);
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', () => {
Expand All @@ -65,11 +68,13 @@ describe('MaterialKpiCards', () => {
});
});

it('applies the blue background to the "Всього" card', () => {
it('applies blue styling to the "Всього" card', () => {
render(<MaterialKpiCards items={ITEMS} />);
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', () => {
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/hooks/useTenantDataBoundaries.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading
Loading