НДС в DI2: вкладка «Расчёты → НДС» переведена на декларационную витрину 1С

- Новый server/finance-di2/nds.routes.ts: GET /api/di2/nds/summary|quarters|input-balance|sync-status|contract-markers
  (только отображение nds_monthly/nds_input_balance/nds_sync_log/nds_contract_marker, без расчёта НДС)
- VatTab переписан: KPI, динамика (месяц с накопительным итогом квартала / квартал), по ставкам,
  stacked по типам операций, доля авансов, резерв вычетов (19 счёт), баннер свежести,
  таблица «деньги есть — отгрузки нет» (RED_NO_SHIPMENT > 1 млн)
- Старый GET /api/calculations/vat (вычисление 20/120 из платежей) удалён
This commit is contained in:
2026-09-29 07:49:23 +03:00
parent c0b8910094
commit de9bbfe17a
4 changed files with 499 additions and 228 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from "react";
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -10,9 +10,8 @@ import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { DatabaseError } from "@/components/database-error";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { CalendarDays, X, TrendingUp, TrendingDown, DollarSign, Receipt, Calculator, Percent, Settings, ChevronRight, ChevronDown, GitBranch, Plus, Trash2, ExternalLink, Link2, Search } from "lucide-react";
import { CalendarDays, X, TrendingUp, TrendingDown, DollarSign, Receipt, Calculator, Settings, ChevronRight, ChevronDown, GitBranch, Plus, Trash2, ExternalLink, Link2, Search, AlertTriangle } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, Line, ComposedChart } from "recharts";
import { formatCurrency } from "@/lib/format";
@@ -288,24 +287,8 @@ interface ChartHighlight {
type: "income" | "expense";
}
const MONTH_NAMES = [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
];
type GroupBy = "month" | "quarter" | "year";
function rateOrder(rate: string): number {
const lower = rate.toLowerCase().trim();
if (lower === "без ндс") return -1;
const m = rate.match(/(\d+)/);
return m ? Number(m[1]) : 0;
}
function sortByRate<T extends { rate: string }>(arr: T[]): T[] {
return [...arr].sort((a, b) => rateOrder(a.rate) - rateOrder(b.rate));
}
function formatPeriodLabel(period: string, groupBy: GroupBy): string {
try {
if (groupBy === "year") return period;
@@ -360,23 +343,6 @@ function groupMonthlyData<T extends { period: string }>(
return [...groups.values()];
}
function addCumulativeVat(
data: { period: string; incomeVat: number; expenseVat: number; toPay: number }[],
startMonth: number,
): (typeof data[number] & { cumToPay: number })[] {
let cumulative = 0;
let currentQuarter = "";
return data.map(row => {
const q = getQuarterKey(row.period, startMonth);
if (q !== currentQuarter) {
cumulative = 0;
currentQuarter = q;
}
cumulative += row.toPay;
return { ...row, cumToPay: Math.round(cumulative * 100) / 100 };
});
}
export default function Calculations() {
const [activeTab, setActiveTab] = useState<"vat" | "profit" | "settings">(
() => (localStorage.getItem("omegaprom_calc_tab") as "vat" | "profit" | "settings") || "vat"
@@ -439,13 +405,42 @@ export default function Calculations() {
const queryString = useMemo(() => new URLSearchParams(dateParams).toString(), [dateParams]);
const vatQuery = useQuery<any>({
queryKey: ["/api/calculations/vat", dateParams],
queryFn: async () => {
const r = await fetch(`/api/calculations/vat?${queryString}`);
if (!r.ok) throw new Error(`${r.status}: ${await r.text() || r.statusText}`);
return r.json();
},
const ndsFetch = async (url: string) => {
const r = await fetch(url);
if (!r.ok) throw new Error(`${r.status}: ${await r.text() || r.statusText}`);
return r.json();
};
// Вкладка «НДС» отображает декларационную витрину 1С (nds_monthly), никаких расчётов на клиенте
const ndsSummaryQuery = useQuery<any>({
queryKey: ["/api/di2/nds/summary", dateParams],
queryFn: () => ndsFetch(`/api/di2/nds/summary?${queryString}`),
enabled: activeTab === "vat",
});
const ndsQuartersQuery = useQuery<any[]>({
queryKey: ["/api/di2/nds/quarters"],
queryFn: () => ndsFetch("/api/di2/nds/quarters"),
enabled: activeTab === "vat",
});
const ndsBalanceQuery = useQuery<any[]>({
queryKey: ["/api/di2/nds/input-balance"],
queryFn: () => ndsFetch("/api/di2/nds/input-balance"),
enabled: activeTab === "vat",
});
const ndsSyncQuery = useQuery<any>({
queryKey: ["/api/di2/nds/sync-status"],
queryFn: () => ndsFetch("/api/di2/nds/sync-status"),
enabled: activeTab === "vat",
refetchInterval: 5 * 60 * 1000,
});
// Маркер «деньги vs отгрузка» по договорам: красные флаги квартала (главный запрос спецификации)
const ndsMarkersQuery = useQuery<any>({
queryKey: ["/api/di2/nds/contract-markers"],
queryFn: () => ndsFetch("/api/di2/nds/contract-markers?marker=RED_NO_SHIPMENT&minPaid=1000000"),
enabled: activeTab === "vat",
});
@@ -480,9 +475,9 @@ export default function Calculations() {
const resetDates = () => { setDateFrom(""); setDateTo(""); };
const hasDates = dateFrom || dateTo;
const isLoading = activeTab === "vat" ? vatQuery.isLoading : activeTab === "profit" ? profitQuery.isLoading : false;
const isError = activeTab === "vat" ? vatQuery.isError : activeTab === "profit" ? profitQuery.isError : false;
const error = activeTab === "vat" ? vatQuery.error : activeTab === "profit" ? profitQuery.error : null;
const isLoading = activeTab === "vat" ? ndsSummaryQuery.isLoading : activeTab === "profit" ? profitQuery.isLoading : false;
const isError = activeTab === "vat" ? ndsSummaryQuery.isError : activeTab === "profit" ? profitQuery.isError : false;
const error = activeTab === "vat" ? ndsSummaryQuery.error : activeTab === "profit" ? profitQuery.error : null;
return (
<div className="p-4 md:p-6 space-y-4">
@@ -559,7 +554,15 @@ export default function Calculations() {
</div>
)}
{activeTab === "vat" && vatQuery.data && !isLoading && <VatTab data={vatQuery.data} />}
{activeTab === "vat" && ndsSummaryQuery.data && !isLoading && (
<VatTab
data={ndsSummaryQuery.data}
quarters={ndsQuartersQuery.data ?? []}
balance={ndsBalanceQuery.data ?? []}
sync={ndsSyncQuery.data ?? null}
markers={ndsMarkersQuery.data ?? null}
/>
)}
{activeTab === "profit" && profitQuery.data && !isLoading && (
<ProfitTab
data={profitQuery.data}
@@ -578,38 +581,73 @@ export default function Calculations() {
);
}
function VatTab({ data }: { data: any }) {
const [groupBy, setGroupBy] = useState<GroupBy>(
() => (localStorage.getItem("omegaprom_vat_groupby") as GroupBy) || "month"
function formatQuarterLabel(q: string): string {
// q = '2026-01-01' (date_trunc quarter)
const quarter = Math.floor((Number(q.slice(5, 7)) - 1) / 3) + 1;
return `${q.slice(0, 4)} Q${quarter}`;
}
function ChartTooltip({ active, payload, label, formatValue }: any) {
if (!active || !payload?.length) return null;
return (
<div className="bg-popover border border-border rounded-md px-3 py-2 shadow-md">
<p className="text-sm font-medium text-foreground mb-2">{label}</p>
{payload.map((entry: any) => (
<div key={entry.dataKey} className="flex items-center gap-2 text-sm py-0.5">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: entry.color }} />
<span className="text-muted-foreground">{entry.name}</span>
<span className="font-medium text-foreground ml-auto tabular-nums">
{formatValue ? formatValue(entry.value) : formatCurrency(entry.value)}
</span>
</div>
))}
</div>
);
const [startMonth, setStartMonth] = useState<number>(
() => Number(localStorage.getItem("omegaprom_vat_start_month") || "1")
}
// Маркеры «деньги vs отгрузка» (словарь спецификации §2.3)
const MARKER_META: Record<string, { label: string; color: string }> = {
RED_NO_SHIPMENT: { label: "Деньги есть — отгрузки нет", color: "#e11d48" },
YELLOW_PAID_GT_SHIPPED: { label: "Оплачено больше отгруженного", color: "#f59e0b" },
BLUE_SHIPPED_GT_PAID: { label: "Отгружено больше оплаченного", color: "#2563eb" },
WHITE_ADVANCE_OPEN: { label: "Расчёты сошлись, аванс не зачтён", color: "#94a3b8" },
GREEN_OK: { label: "Сошлось", color: "#16a34a" },
};
function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarters: any[]; balance: any[]; sync: any; markers: any }) {
const [groupBy, setGroupBy] = useState<"month" | "quarter">(
() => (localStorage.getItem("omegaprom_vat_groupby") === "quarter" ? "quarter" : "month")
);
function changeGroupBy(v: GroupBy) {
function changeGroupBy(v: "month" | "quarter") {
setGroupBy(v);
localStorage.setItem("omegaprom_vat_groupby", v);
}
function changeStartMonth(v: string) {
const m = Number(v);
setStartMonth(m);
localStorage.setItem("omegaprom_vat_start_month", String(m));
}
// Все суммы приходят готовыми из витрины: помесячно (с накопительным итогом квартала) и поквартально
const chartData = groupBy === "month" ? (data.monthly ?? []) : quarters;
const periodLabel = (v: string) => (groupBy === "month" ? formatPeriodLabel(v, "month") : formatQuarterLabel(v));
const chartData = useMemo(() => {
if (!data.monthly?.length) return [];
const grouped = groupMonthlyData(
data.monthly,
groupBy,
startMonth,
["incomeVat", "expenseVat", "toPay"] as any[],
);
if (groupBy === "month") {
return addCumulativeVat(grouped as any, startMonth);
// Поворот (period, event, vat) в столбцы для stacked-диаграммы — чистое отображение, без расчётов
const eventNames = useMemo(() => {
const names = new Set<string>();
for (const r of data.events ?? []) names.add(r.event);
return [...names];
}, [data.events]);
const eventsChart = useMemo(() => {
const byPeriod = new Map<string, any>();
for (const r of data.events ?? []) {
if (!byPeriod.has(r.period)) byPeriod.set(r.period, { period: r.period });
byPeriod.get(r.period)[r.event] = r.vat;
}
return grouped.map((r: any) => ({ ...r, cumToPay: undefined }));
}, [data.monthly, groupBy, startMonth]);
return [...byPeriod.values()].sort((a, b) => a.period.localeCompare(b.period));
}, [data.events]);
const salesByRate = (data.byRate ?? []).filter((r: any) => r.direction === "sales");
const purchasesByRate = (data.byRate ?? []).filter((r: any) => r.direction === "purchases");
const totals = data.totals ?? { accrued: 0, deducted: 0, toPay: 0 };
const redRows: any[] = markers?.rows ?? [];
const markerCounts: any[] = markers?.byMarker ?? [];
return (
<div className="space-y-4">
@@ -620,9 +658,9 @@ function VatTab({ data }: { data: any }) {
<div className="w-8 h-8 rounded-lg bg-[color-mix(in_oklab,var(--info)_10%,transparent)] flex items-center justify-center">
<TrendingUp className="w-4 h-4 text-info" />
</div>
<span className="text-sm text-muted-foreground">НДС с доходов</span>
<span className="text-sm text-muted-foreground">Начислено</span>
</div>
<p className="text-xl font-bold text-foreground tabular-nums" data-testid="text-income-vat">{formatCurrency(data.totalIncomeVat)}</p>
<p className="text-xl font-bold text-foreground tabular-nums" data-testid="text-income-vat">{formatCurrency(totals.accrued)}</p>
</CardContent>
</Card>
<Card>
@@ -631,9 +669,9 @@ function VatTab({ data }: { data: any }) {
<div className="w-8 h-8 rounded-lg bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)] flex items-center justify-center">
<TrendingDown className="w-4 h-4 text-destructive" />
</div>
<span className="text-sm text-muted-foreground">НДС с расходов</span>
<span className="text-sm text-muted-foreground">Вычет</span>
</div>
<p className="text-xl font-bold text-foreground tabular-nums" data-testid="text-expense-vat">{formatCurrency(data.totalExpenseVat)}</p>
<p className="text-xl font-bold text-foreground tabular-nums" data-testid="text-expense-vat">{formatCurrency(totals.deducted)}</p>
</CardContent>
</Card>
<Card>
@@ -644,46 +682,45 @@ function VatTab({ data }: { data: any }) {
</div>
<span className="text-sm text-muted-foreground">К уплате</span>
</div>
<p className={`text-xl font-bold tabular-nums ${data.toPay >= 0 ? "text-destructive" : "text-success"}`} data-testid="text-vat-to-pay">
{formatCurrency(data.toPay)}
<p className={`text-xl font-bold tabular-nums ${totals.toPay >= 0 ? "text-destructive" : "text-success"}`} data-testid="text-vat-to-pay">
{formatCurrency(totals.toPay)}
</p>
</CardContent>
</Card>
</div>
<div className="space-y-1">
{sync?.stale && (
<div className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-foreground" data-testid="banner-nds-stale">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-destructive" />
Данные витрины устарели — синхронизация с 1С не выполнялась более 36 часов
</div>
)}
<p className="text-xs text-muted-foreground">
Источник: декларационная витрина 1С (книга продаж, книга покупок, счёт 19)
{sync?.startedAt ? ` · синхронизировано ${new Date(sync.startedAt).toLocaleString("ru-RU")}` : ""}
{data.dateFrom && data.dateTo ? ` · период: ${data.dateFrom} — ${data.dateTo}` : ""}
</p>
</div>
{chartData.length > 0 && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Динамика НДС</CardTitle>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Начало квартала:</span>
<Select value={String(startMonth)} onValueChange={changeStartMonth}>
<SelectTrigger className="h-7 w-[130px] text-xs" data-testid="select-start-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_NAMES.map((name, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-0.5 bg-[color-mix(in_oklab,var(--muted)_30%,transparent)] p-0.5 rounded-md">
{([["month", "Месяц"], ["quarter", "Квартал"], ["year", "Год"]] as const).map(([key, label]) => (
<Button
key={key}
variant={groupBy === key ? "default" : "ghost"}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => changeGroupBy(key)}
data-testid={`button-groupby-${key}`}
>
{label}
</Button>
))}
</div>
<div className="flex gap-0.5 bg-[color-mix(in_oklab,var(--muted)_30%,transparent)] p-0.5 rounded-md">
{([["month", "Месяц"], ["quarter", "Квартал"]] as const).map(([key, label]) => (
<Button
key={key}
variant={groupBy === key ? "default" : "ghost"}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => changeGroupBy(key)}
data-testid={`button-groupby-${key}`}
>
{label}
</Button>
))}
</div>
</div>
</CardHeader>
@@ -693,62 +730,119 @@ function VatTab({ data }: { data: any }) {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="period"
tickFormatter={(v: string) => formatPeriodLabel(v, groupBy)}
tickFormatter={periodLabel}
tick={{ fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={tickFormatter} />
<Tooltip
content={({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-popover border border-border rounded-md px-3 py-2 shadow-md">
<p className="text-sm font-medium text-foreground mb-2">{formatPeriodLabel(label, groupBy)}</p>
{payload.map((entry: any) => (
<div key={entry.dataKey} className="flex items-center gap-2 text-sm py-0.5">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: entry.color }} />
<span className="text-muted-foreground">{entry.name}</span>
<span className="font-medium text-foreground ml-auto tabular-nums">{formatCurrency(entry.value)}</span>
</div>
))}
</div>
);
}}
/>
<Tooltip content={(props: any) => <ChartTooltip {...props} label={periodLabel(props.label)} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Bar dataKey="incomeVat" name="НДС с доходов" fill={COLORS.income} radius={[3, 3, 0, 0]} />
<Bar dataKey="expenseVat" name="НДС с расходов" fill={COLORS.expense} radius={[3, 3, 0, 0]} />
<Bar dataKey="accrued" name="Начислено" fill={COLORS.income} radius={[3, 3, 0, 0]} />
<Bar dataKey="deducted" name="Вычет" fill={COLORS.expense} radius={[3, 3, 0, 0]} />
<Line dataKey="toPay" name="К уплате" stroke={COLORS.toPay} strokeWidth={2} dot={{ r: 3 }} />
{groupBy === "month" && (
<Line dataKey="cumToPay" name="Накоп. за квартал" stroke={COLORS.cumulative} strokeWidth={2} dot={{ r: 3 }} strokeDasharray="5 5" />
<Line dataKey="vatCumulative" name="Накоп. за квартал" stroke={COLORS.cumulative} strokeWidth={2} dot={{ r: 3 }} strokeDasharray="5 5" />
)}
</ComposedChart>
</ResponsiveContainer>
{groupBy === "month" && (
<p className="text-xs text-muted-foreground mt-2">
Пунктирная линия — накопительный итог к уплате, сбрасывается в начале каждого квартала (с {MONTH_NAMES[startMonth - 1].toLowerCase()})
Пунктирная линия — накопительный итог внутри календарного квартала. НДС не переносится между кварталами: «к уплате» считается заново каждый квартал
</p>
)}
</CardContent>
</Card>
)}
{eventsChart.length > 0 && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground">Начисления по типам операций</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={eventsChart}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="period" tickFormatter={(v: string) => formatPeriodLabel(v, "month")} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={tickFormatter} />
<Tooltip content={(props: any) => <ChartTooltip {...props} label={formatPeriodLabel(props.label, "month")} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
{eventNames.map((name, i) => (
<Bar key={name} dataKey={name} stackId="vat" name={name} fill={HIGHLIGHT_COLORS[i % HIGHLIGHT_COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
<p className="text-xs text-muted-foreground mt-2">
Всплеск «Получен аванс» — уплата НДС с аванса до отгрузки; при отгрузке авансовая строка сторнируется
</p>
</CardContent>
</Card>
)}
{markers && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Контроль квартала: деньги есть — отгрузки нет
</CardTitle>
<div className="flex items-center gap-2 flex-wrap">
{markerCounts.map((m: any) => {
const meta = MARKER_META[m.marker] ?? { label: m.marker, color: "#94a3b8" };
return (
<span key={m.marker} className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" title={meta.label}>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: meta.color }} />
{m.count}
</span>
);
})}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{redRows.length > 0 ? (
<div className="space-y-1">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-3 text-xs text-muted-foreground pb-1 border-b border-border">
<span>Контрагент</span>
<span>Договор</span>
<span className="text-right">Оплачено с начала года</span>
<span className="text-right">Аванс на 62.02</span>
</div>
{redRows.map((r: any, i: number) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-3 text-sm py-1 items-baseline">
<span className="text-foreground">{r.counterparty}</span>
<span className="text-muted-foreground truncate" title={r.contract || "Договор в документах не заполнен"}>
{r.contract || "— договор не заполнен —"}
</span>
<span className="tabular-nums text-right font-medium text-foreground">{formatCurrency(r.paid)}</span>
<span className="tabular-nums text-right text-muted-foreground">{formatCurrency(r.advanceBalance)}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Крупных необеспеченных авансов (более 1 млн ₽ без отгрузки) нет</p>
)}
<p className="text-xs text-muted-foreground mt-3">
Договоры со сверкой: {markerCounts.reduce((s: number, m: any) => s + m.count, 0)}. НДС с этих авансов уже попал в «к уплате», а выручка и зачёт — в будущем.
Пустой договор — в платежах/реализациях договор не выбран (самостоятельная проблема учёта).
</p>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground">По ставкам (доходы)</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">По ставкам (начислено)</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="space-y-2">
{sortByRate(data.incomeByRate).map((r: any) => (
{salesByRate.map((r: any) => (
<div key={r.rate} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{r.rate}</span>
<span className="text-xs text-[color-mix(in_oklab,var(--muted-foreground)_60%,transparent)]">({r.count} док.)</span>
</div>
<span className="text-muted-foreground">{r.rate}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground tabular-nums">{formatCurrency(r.total)}</span>
<span className="text-muted-foreground tabular-nums" title="Сумма без НДС">{formatCurrency(r.totalNoVat)}</span>
<span className="font-medium text-foreground tabular-nums min-w-[100px] text-right" data-testid={`text-vat-income-${r.rate}`}>
НДС: {formatCurrency(r.vat)}
</span>
@@ -760,18 +854,15 @@ function VatTab({ data }: { data: any }) {
</Card>
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground">По ставкам (расходы)</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">По ставкам (вычет)</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="space-y-2">
{sortByRate(data.expenseByRate).map((r: any) => (
{purchasesByRate.map((r: any) => (
<div key={r.rate} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{r.rate}</span>
<span className="text-xs text-[color-mix(in_oklab,var(--muted-foreground)_60%,transparent)]">({r.count} док.)</span>
</div>
<span className="text-muted-foreground">{r.rate}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground tabular-nums">{formatCurrency(r.total)}</span>
<span className="text-muted-foreground tabular-nums" title="Сумма без НДС">{formatCurrency(r.totalNoVat)}</span>
<span className="font-medium text-foreground tabular-nums min-w-[100px] text-right" data-testid={`text-vat-expense-${r.rate}`}>
НДС: {formatCurrency(r.vat)}
</span>
@@ -782,6 +873,50 @@ function VatTab({ data }: { data: any }) {
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{(data.advances ?? []).length > 0 && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground">Доля авансов в начислениях</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={data.advances}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="period" tickFormatter={(v: string) => formatPeriodLabel(v, "month")} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip content={(props: any) => <ChartTooltip {...props} label={formatPeriodLabel(props.label, "month")} formatValue={(v: number) => `${v} %`} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Bar dataKey="pctAdvances" name="Доля авансов, %" fill={COLORS.tax} radius={[3, 3, 0, 0]} />
<Line dataKey="vatTotal" name="Начислено всего" stroke={COLORS.income} strokeWidth={2} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{balance.length > 0 && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm font-medium text-muted-foreground">Резерв будущих вычетов (счёт 19)</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={balance}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" tickFormatter={(v: string) => `${v.slice(8, 10)}.${v.slice(5, 7)}`} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={tickFormatter} />
<Tooltip content={(props: any) => <ChartTooltip {...props} label={props.label} />} />
<Line dataKey="vatBalance" name="НДС, не принятый к вычету" stroke={COLORS.cumulative} strokeWidth={2} dot={{ r: 2 }} />
</ComposedChart>
</ResponsiveContainer>
<p className="text-xs text-muted-foreground mt-2">
Входящий НДС, предъявленный поставщиками, но ещё не принятый к вычету
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}