НДС в 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user