Финансы: дебиторка (vw_debt_active), расчёты НДС/налога на прибыль, аудит целостности с автопланировщиком, синхронизация зарплат из Google Sheets, миграция настроек Data-Insight2

This commit is contained in:
2026-09-05 15:07:36 +03:00
parent d2c993b613
commit 7a8db03163
17 changed files with 4809 additions and 34 deletions

View File

@@ -67,6 +67,9 @@ const IncomeCategories = safeLazy(() => import("@/pages/finance/income-categorie
const ExpenseCategories = safeLazy(() => import("@/pages/finance/expense-categories"));
const FinanceSettingsPage = safeLazy(() => import("@/pages/finance/settings"));
const DebtPage = safeLazy(() => import("@/pages/finance/debt"));
const CalculationsPage = safeLazy(() => import("@/pages/finance/calculations"));
const AuditPage = safeLazy(() => import("@/pages/finance/audit"));
const DocumentsPage = safeLazy(() => import("@/pages/documents/DocumentsPage").then(m => ({ default: m.DocumentsPage })));
const TemplateEditorPage = safeLazy(() => import("@/pages/documents/TemplateEditorPage").then(m => ({ default: m.TemplateEditorPage })));
const GenerationHistoryPage = safeLazy(() => import("@/pages/documents/GenerationHistoryPage").then(m => ({ default: m.GenerationHistoryPage })));
@@ -111,6 +114,9 @@ const ROUTE_TITLES: Array<[RegExp, string]> = [
[/^\/settings/, 'Настройки'],
[/^\/profile/, 'Профиль'],
[/^\/billing/, 'Подписка и биллинг'],
[/^\/finance\/debt/, 'Дебиторка'],
[/^\/finance\/calculations/, 'Расчёты'],
[/^\/finance\/audit/, 'Аудит'],
[/^\/finance/, 'Финансы'],
[/^\/documents/, 'Документы'],
[/^\/reports/, 'Отчёты'],
@@ -179,6 +185,9 @@ function Router() { return (
<Route path="/gps/:tab" component={GpsPage} />
<Route path="/billing" component={Billing} />
<Route path="/finance" component={FinanceDashboard} />
<Route path="/finance/debt" component={DebtPage} />
<Route path="/finance/calculations" component={CalculationsPage} />
<Route path="/finance/audit" component={AuditPage} />
<Route path="/income" component={IncomeOverview} />
<Route path="/income/uncategorized" component={IncomeDetail} />
<Route path="/income/categories" component={IncomeCategories} />

View File

@@ -3,6 +3,9 @@ import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Tags,
Wallet,
Calculator,
ShieldCheck,
} from "lucide-react";
export type FinanceTab =
@@ -12,7 +15,10 @@ export type FinanceTab =
| "income-categories"
| "expense-categories"
| "settings"
| "salary";
| "salary"
| "debt"
| "calculations"
| "audit";
interface FinanceNavProps {
active: FinanceTab;
@@ -21,6 +27,9 @@ interface FinanceNavProps {
const items: { key: FinanceTab; label: string; icon: React.ReactNode; href?: string }[] = [
{ key: "dashboard", label: "Обзор", icon: <LayoutDashboard className="w-4 h-4" /> },
{ key: "debt", label: "Дебиторка", icon: <Wallet className="w-4 h-4" />, href: "/finance/debt" },
{ key: "calculations", label: "Расчёты", icon: <Calculator className="w-4 h-4" />, href: "/finance/calculations" },
{ key: "audit", label: "Аудит", icon: <ShieldCheck className="w-4 h-4" />, href: "/finance/audit" },
{ key: "income-categories", label: "Категории доходов", icon: <Tags className="w-4 h-4" /> },
{ key: "expense-categories", label: "Категории расходов", icon: <Tags className="w-4 h-4" /> },
];

View File

@@ -999,3 +999,452 @@ export function useContractSyncApply() {
},
});
}
// =====================
// Debt (Дебиторская задолженность)
// =====================
export interface DebtSummary {
updatedAt: string | null;
totalDebt: number;
totalOverdue: number;
}
export interface DebtClassRow {
name: string;
total: number;
cls0: number;
cls1_30: number;
cls31_60: number;
cls61_90: number;
cls91_365: number;
cls365plus: number;
}
export interface DebtOverdueClassRow {
cls: string;
total: number;
}
export interface DebtDetailRow {
dogovor: string;
summadokumenta: number;
dokumentraschetov: string;
summaoplacheno: number;
summadolga: number;
summaprosrocheno: number;
otsrochkapodogovoru: string;
dneiprosrocheno: number;
}
export interface DebtTableGroup {
kontragent: string;
totalDebt: number;
totalOverdue: number;
rows: DebtDetailRow[];
}
export interface DebtAnalytics {
success: boolean;
summary: DebtSummary;
byKontragent: DebtClassRow[];
byManager: DebtClassRow[];
byOverdueClass: DebtOverdueClassRow[];
table: {
rows: DebtTableGroup[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
export interface DebtFiltersResponse {
success: boolean;
filters: {
periods: string[];
klassprosrochki: string[];
organizaciya: string[];
otvetstvennii: string[];
kontragenty: string[];
};
}
export interface DebtDynamicsResponse {
success: boolean;
rows: { period: string; totalDebt: number; totalOverdue: number }[];
}
export interface DebtDynamicsKontragent {
kontragent: string;
summadokumenta: number;
summaoplacheno: number;
summadolga: number;
summaprosrocheno: number;
dneiprosrocheno: number;
}
export interface DebtAnalyticsParams {
period?: string;
klassprosrochki?: string[];
kontragent?: string[];
dogovor?: string;
otvetstvennii?: string[];
page?: number;
pageSize?: number;
}
export function useDebtAnalytics(params: DebtAnalyticsParams) {
return useQuery<DebtAnalytics>({
queryKey: ["/api/debt/analytics", JSON.stringify(params)],
queryFn: async () => {
const sp = new URLSearchParams();
if (params.period) sp.set("period", params.period);
if (params.klassprosrochki?.length) sp.set("klassprosrochki", params.klassprosrochki.join(","));
if (params.kontragent?.length) sp.set("kontragent", params.kontragent.join(","));
if (params.dogovor) sp.set("dogovor", params.dogovor);
if (params.otvetstvennii?.length) sp.set("otvetstvennii", params.otvetstvennii.join(","));
sp.set("page", String(params.page ?? 1));
sp.set("pageSize", String(params.pageSize ?? 20));
const res = await apiRequest("GET", `/api/debt/analytics?${sp.toString()}`);
return res.json();
},
});
}
export function useDebtFilters() {
return useQuery<DebtFiltersResponse>({
queryKey: ["/api/debt/filters"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/debt/filters");
return res.json();
},
});
}
export function useDebtDynamics(dateFrom?: string, dateTo?: string, kontragent?: string) {
return useQuery<DebtDynamicsResponse>({
queryKey: ["/api/debt/dynamics", dateFrom, dateTo, kontragent],
queryFn: async () => {
const sp = new URLSearchParams();
if (dateFrom) sp.set("dateFrom", dateFrom);
if (dateTo) sp.set("dateTo", dateTo);
if (kontragent) sp.set("kontragent", kontragent);
const res = await apiRequest("GET", `/api/debt/dynamics?${sp.toString()}`);
return res.json();
},
});
}
export function useDebtKontragenty(period?: string, dateTo?: string) {
return useQuery<{ success: boolean; rows: DebtDynamicsKontragent[] }>({
queryKey: ["/api/debt/dynamics/kontragenty", period, dateTo],
queryFn: async () => {
const sp = new URLSearchParams();
if (period) sp.set("period", period);
if (dateTo) sp.set("dateTo", dateTo);
const qs = sp.toString();
const res = await apiRequest("GET", `/api/debt/dynamics/kontragenty${qs ? `?${qs}` : ""}`);
return res.json();
},
});
}
// =====================
// Calculations (НДС и налог на прибыль)
// =====================
export interface VatRateRow {
rate: string;
count: number;
total: number;
vat: number;
}
export interface VatResponse {
success: boolean;
incomeByRate: VatRateRow[];
expenseByRate: VatRateRow[];
totalIncomeVat: number;
totalExpenseVat: number;
toPay: number;
monthly: { period: string; incomeVat: number; expenseVat: number; toPay: number }[];
}
export function useVat(dateFrom?: string, dateTo?: string) {
return useQuery<VatResponse>({
queryKey: ["/api/calculations/vat", dateFrom, dateTo],
queryFn: async () => {
const sp = new URLSearchParams();
if (dateFrom) sp.set("dateFrom", dateFrom);
if (dateTo) sp.set("dateTo", dateTo);
const res = await apiRequest("GET", `/api/calculations/vat?${sp.toString()}`);
return res.json();
},
});
}
export interface ChartCategoryRef {
id: string;
type: "income" | "expense";
name?: string;
}
export interface ProfitTaxParams {
dateFrom?: string;
dateTo?: string;
taxRate: number;
incomeCategories?: string[];
expenseCategories?: string[];
chartCategories?: ChartCategoryRef[];
}
export interface ProfitTaxResponse {
success: boolean;
income: number;
expenses: number;
profit: number;
taxRate: number;
tax: number;
monthly: { period: string; income: number; expenses: number; profit: number; tax: number }[];
incomeWithVatBase: number;
expenseWithVatBase: number;
incomeNoVat: number;
expenseNoVat: number;
excludedIncomeCategories: { name: string; total: number }[];
excludedExpenseCategories: { name: string; total: number }[];
categoryMonthly?: { id: string; name: string; type: string; data: { period: string; amount: number }[] }[];
}
export function useProfitTax() {
return useMutation<ProfitTaxResponse, Error, ProfitTaxParams>({
mutationFn: async (params) => {
const sp = new URLSearchParams();
if (params.dateFrom) sp.set("dateFrom", params.dateFrom);
if (params.dateTo) sp.set("dateTo", params.dateTo);
sp.set("taxRate", String(params.taxRate));
const body: Record<string, unknown> = {};
if (params.incomeCategories?.length) body.incomeCategories = params.incomeCategories;
if (params.expenseCategories?.length) body.expenseCategories = params.expenseCategories;
if (params.chartCategories?.length) body.chartCategories = params.chartCategories;
const res = await apiRequest("POST", `/api/calculations/profit-tax?${sp.toString()}`, body);
return res.json();
},
});
}
// =====================
// Audit (проверка распределения по категориям)
// =====================
export interface AuditGapRow {
columns: Record<string, string>;
sum: number;
count: number;
}
export interface DirectionAudit {
dbTotal: number;
dbCount: number;
treeTotal: number;
treeCount: number;
excludedTotal: number;
excludedCount: number;
uncategorizedTotal: number;
uncategorizedCount: number;
gapTotal: number;
gapCount: number;
gapRows: AuditGapRow[];
treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[];
}
export interface AuditResult {
timestamp: string;
status: "ok" | "warning" | "error";
summary: string;
income: DirectionAudit;
expense: DirectionAudit;
}
export function useAuditHistory() {
return useQuery<{ success: boolean; history: AuditResult[] }>({
queryKey: ["/api/audit/history"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/audit/history");
return res.json();
},
});
}
export function useAuditStatus() {
return useQuery<{ success: boolean; last: AuditResult | null; running: boolean }>({
queryKey: ["/api/audit/status"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/audit/status");
return res.json();
},
refetchInterval: 10_000,
});
}
export function useRunAudit() {
const qc = useQueryClient();
const { toast } = useToast();
return useMutation<{ success: boolean; result: AuditResult }, Error>({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/audit/run");
return res.json();
},
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ["/api/audit/history"] });
qc.invalidateQueries({ queryKey: ["/api/audit/status"] });
toast({
title: "Аудит завершён",
description: data?.result?.status === "ok" ? "Расхождений не найдено" : "Обнаружены расхождения",
variant: data?.result?.status === "ok" ? "default" : "destructive",
});
},
onError: (err: any) => {
toast({ title: "Ошибка аудита", description: err?.message, variant: "destructive" });
},
});
}
// =====================
// Salary: Google Sheets source
// =====================
export interface GoogleStatus {
success: boolean;
loggedIn: boolean;
email?: string;
}
export function useGoogleStatus() {
return useQuery<GoogleStatus>({
queryKey: ["/api/finance/google/status"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/finance/google/status");
return res.json();
},
});
}
export function useGoogleAuthUrl() {
return useQuery<{ success: boolean; url: string }>({
queryKey: ["/api/finance/google/auth-url"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/finance/google/auth-url");
return res.json();
},
enabled: false,
retry: false,
});
}
export interface SalaryArea {
id: string;
name: string;
spreadsheetId?: string;
spreadsheetUrl?: string;
fioCol?: string;
bazaCol?: string;
kVydacheCol?: string;
}
export function useSalaryAreas() {
return useQuery<{ success: boolean; areas: SalaryArea[] }>({
queryKey: ["/api/salary/areas"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/salary/areas");
return res.json();
},
});
}
export function useCreateSalaryArea() {
const qc = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (body: { name: string; spreadsheetUrl: string; fioCol?: string; bazaCol?: string; kVydacheCol?: string }) => {
const res = await apiRequest("POST", "/api/salary/areas", body);
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/salary/areas"] });
qc.invalidateQueries({ queryKey: ["/api/salary/neof"] });
toast({ title: "Участок добавлен" });
},
onError: (err: any) => {
toast({ title: "Ошибка", description: err?.message, variant: "destructive" });
},
});
}
export function useUpdateSalaryArea() {
const qc = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({ id, ...body }: { id: string; fioCol?: string; bazaCol?: string; kVydacheCol?: string }) => {
const res = await apiRequest("PATCH", `/api/salary/areas/${id}`, body);
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/salary/areas"] });
qc.invalidateQueries({ queryKey: ["/api/salary/neof"] });
toast({ title: "Столбцы обновлены" });
},
onError: (err: any) => {
toast({ title: "Ошибка", description: err?.message, variant: "destructive" });
},
});
}
export function useDeleteSalaryArea() {
const qc = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (id: string) => {
const res = await apiRequest("DELETE", `/api/salary/areas/${id}`);
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/salary/areas"] });
qc.invalidateQueries({ queryKey: ["/api/salary/neof"] });
toast({ title: "Участок удалён" });
},
onError: (err: any) => {
toast({ title: "Ошибка", description: err?.message, variant: "destructive" });
},
});
}
export interface SalarySyncResult {
success: boolean;
added: number;
updated: number;
deleted: number;
total: number;
errors?: string[];
authRequired?: boolean;
}
export function useSalarySync() {
const qc = useQueryClient();
return useMutation<SalarySyncResult, Error>({
mutationFn: async () => {
try {
const res = await apiRequest("POST", "/api/salary/sync");
return res.json();
} catch (err: any) {
// 401 с authRequired — не ошибка, а сигнал «нужна авторизация Google»
if (err?.status === 401) return { success: false, authRequired: true } as SalarySyncResult;
throw err;
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/salary/neof"] });
qc.invalidateQueries({ queryKey: ["/api/salary/db-status"] });
},
});
}

View File

@@ -0,0 +1,278 @@
import { useState } from "react";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceNav } from "@/components/finance/FinanceNav";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Loader2, Play, CheckCircle2, AlertTriangle, Clock, RefreshCw, ChevronDown, ChevronRight } from "lucide-react";
import {
useAuditHistory,
useAuditStatus,
useRunAudit,
type AuditResult,
type DirectionAudit,
} from "@/hooks/useFinance";
function formatMoney(v: number): string {
return v.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₽";
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
function StatusBadge({ status }: { status: string }) {
if (status === "ok") return <Badge className="bg-green-500/10 text-green-600 border-green-500/20 hover:bg-green-500/10"><CheckCircle2 className="w-3 h-3 mr-1" /> Всё в порядке</Badge>;
if (status === "warning") return <Badge variant="outline" className="bg-yellow-500/10 text-yellow-600 border-yellow-500/20"><AlertTriangle className="w-3 h-3 mr-1" /> Есть расхождения</Badge>;
return <Badge variant="destructive"><AlertTriangle className="w-3 h-3 mr-1" /> Ошибка</Badge>;
}
const MAX_GAP_ROWS = 50;
function DirectionCard({ title, data }: { title: string; data: DirectionAudit }) {
const pctTree = data.dbCount > 0 ? ((data.treeCount / data.dbCount) * 100).toFixed(1) : "0";
const pctExcl = data.dbCount > 0 ? ((data.excludedCount / data.dbCount) * 100).toFixed(1) : "0";
const pctUncat = data.dbCount > 0 ? ((data.uncategorizedCount / data.dbCount) * 100).toFixed(1) : "0";
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{title}</CardTitle>
{data.gapCount === 0
? <Badge className="bg-green-500/10 text-green-600 border-green-500/20 hover:bg-green-500/10">Gap: 0</Badge>
: <Badge variant="outline" className="bg-yellow-500/10 text-yellow-600 border-yellow-500/20">Gap: {data.gapCount} строк</Badge>
}
</div>
</CardHeader>
<CardContent className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Категория</TableHead>
<TableHead className="text-right">Строк</TableHead>
<TableHead className="text-right">%</TableHead>
<TableHead className="text-right">Сумма</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="font-medium bg-muted/30">
<TableCell>Всего в БД</TableCell>
<TableCell className="text-right">{data.dbCount}</TableCell>
<TableCell className="text-right">100%</TableCell>
<TableCell className="text-right">{formatMoney(data.dbTotal)}</TableCell>
</TableRow>
<TableRow>
<TableCell>В деревьях (не исключённые)</TableCell>
<TableCell className="text-right">{data.treeCount}</TableCell>
<TableCell className="text-right">{pctTree}%</TableCell>
<TableCell className="text-right">{formatMoney(data.treeTotal)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Исключённые категории</TableCell>
<TableCell className="text-right">{data.excludedCount}</TableCell>
<TableCell className="text-right">{pctExcl}%</TableCell>
<TableCell className="text-right">{formatMoney(data.excludedTotal)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Нераспределённые</TableCell>
<TableCell className="text-right">{data.uncategorizedCount}</TableCell>
<TableCell className="text-right">{pctUncat}%</TableCell>
<TableCell className="text-right">{formatMoney(data.uncategorizedTotal)}</TableCell>
</TableRow>
<TableRow className={data.gapCount !== 0 ? "bg-yellow-500/5" : "bg-green-500/5"}>
<TableCell className="font-medium">Gap (расхождение)</TableCell>
<TableCell className="text-right font-medium">{data.gapCount}</TableCell>
<TableCell className="text-right">—</TableCell>
<TableCell className="text-right font-medium">{formatMoney(data.gapTotal)}</TableCell>
</TableRow>
</TableBody>
</Table>
{data.treeBreakdown.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Деревья</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead>Дерево</TableHead>
<TableHead className="text-right">Строк</TableHead>
<TableHead className="text-right">Сумма</TableHead>
<TableHead className="text-center">Primary</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.treeBreakdown.map((t, i) => (
<TableRow key={i}>
<TableCell>{t.name}</TableCell>
<TableCell className="text-right">{t.count}</TableCell>
<TableCell className="text-right">{formatMoney(t.total)}</TableCell>
<TableCell className="text-center">
{t.isPrimary ? <CheckCircle2 className="w-3.5 h-3.5 text-green-500 mx-auto" /> : <span className="text-muted-foreground text-xs">—</span>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{data.gapRows.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 text-yellow-600">Нераспределённые строки (gap)</h4>
<div className="max-h-60 overflow-auto border rounded-md">
<Table>
<TableHeader>
<TableRow>
{Object.keys(data.gapRows[0].columns).slice(0, 5).map(col => (
<TableHead key={col} className="text-xs">{col}</TableHead>
))}
<TableHead className="text-right text-xs">Сумма</TableHead>
<TableHead className="text-right text-xs">Кол-во</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.gapRows.slice(0, MAX_GAP_ROWS).map((r, i) => (
<TableRow key={i}>
{Object.keys(data.gapRows[0].columns).slice(0, 5).map(col => (
<TableCell key={col} className="text-xs max-w-[150px] truncate">{r.columns[col] || "—"}</TableCell>
))}
<TableCell className="text-right text-xs">{formatMoney(r.sum)}</TableCell>
<TableCell className="text-right text-xs">{r.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{data.gapRows.length > MAX_GAP_ROWS && (
<p className="text-xs text-muted-foreground mt-1">
Показаны первые {MAX_GAP_ROWS} из {data.gapRows.length} строк
</p>
)}
</div>
)}
</CardContent>
</Card>
);
}
function AuditEntry({ audit, defaultOpen }: { audit: AuditResult; defaultOpen: boolean }) {
const [open, setOpen] = useState(defaultOpen);
return (
<Card>
<button
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
onClick={() => setOpen(o => !o)}
>
<div className="flex items-center gap-3 min-w-0">
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" /> : <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />}
<span className="text-sm whitespace-nowrap">{formatDateTime(audit.timestamp)}</span>
<StatusBadge status={audit.status} />
</div>
<div className="flex items-center gap-4 min-w-0">
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:block">
Доходы gap: {audit.income.gapCount} / {formatMoney(audit.income.gapTotal)} · Расходы gap: {audit.expense.gapCount} / {formatMoney(audit.expense.gapTotal)}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[240px]">{audit.summary}</span>
</div>
</button>
{open && (
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
<DirectionCard title="Доходы" data={audit.income} />
<DirectionCard title="Расходы" data={audit.expense} />
</div>
</CardContent>
)}
</Card>
);
}
export default function AuditPage() {
const { data: historyData, isLoading: historyLoading } = useAuditHistory();
const { data: statusData } = useAuditStatus();
const runAudit = useRunAudit();
const history = historyData?.history ?? [];
const latestAudit = statusData?.last ?? history[0] ?? null;
const running = statusData?.running || runAudit.isPending;
return (
<FinanceLayout>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Аудит данных</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Проверка целостности распределения строк по категориям
</p>
</div>
<div className="flex items-center gap-3">
{latestAudit && <StatusBadge status={latestAudit.status} />}
<Button
onClick={() => runAudit.mutate()}
disabled={running}
className="gap-2"
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? "Аудит выполняется…" : "Запустить аудит"}
</Button>
</div>
</div>
<FinanceNav active="audit" onChange={() => {}} />
{historyLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Загрузка...</span>
</div>
)}
{latestAudit && (
<Card className="bg-muted/30">
<CardContent className="py-4">
<div className="flex items-center gap-3 flex-wrap">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Последний аудит: {formatDateTime(latestAudit.timestamp)}
</span>
<span className="text-sm">{latestAudit.summary}</span>
</div>
</CardContent>
</Card>
)}
{!historyLoading && history.length === 0 && !latestAudit && (
<Card>
<CardContent className="py-12 text-center">
<RefreshCw className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">Аудит ещё не проводился</p>
<p className="text-sm text-muted-foreground mt-1">Нажмите «Запустить аудит» для первой проверки</p>
</CardContent>
</Card>
)}
{history.length > 0 && (
<div className="space-y-3">
{history.map((h, i) => (
<AuditEntry key={`${h.timestamp}-${i}`} audit={h} defaultOpen={i === 0} />
))}
</div>
)}
</div>
</FinanceLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,651 @@
import { useState, useMemo, Fragment } from "react";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceNav } from "@/components/finance/FinanceNav";
import { CopyableCell } from "@/components/finance/CopyableCell";
import { DatabaseError } from "@/components/finance/DatabaseError";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import {
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
RotateCcw,
Clock,
Search,
Filter,
} from "lucide-react";
import { formatCurrency, formatNumber } from "@/lib/format";
import {
useDebtAnalytics,
useDebtFilters,
useDebtDynamics,
useDebtKontragenty,
type DebtAnalytics,
} from "@/hooks/useFinance";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
LineChart,
Line,
Cell,
} from "recharts";
const OVERDUE_COLORS: Record<string, string> = {
cls0: "#22c55e",
cls1_30: "#3b82f6",
cls31_60: "#eab308",
cls61_90: "#f97316",
cls91_365: "#ef4444",
cls365plus: "#991b1b",
};
const OVERDUE_LABELS: Record<string, string> = {
cls0: "Не просрочено",
cls1_30: "1-30",
cls31_60: "31-60",
cls61_90: "61-90",
cls91_365: "91-365",
cls365plus: "> 365",
};
const CLS_KEYS = ["cls0", "cls1_30", "cls31_60", "cls61_90", "cls91_365", "cls365plus"] as const;
function formatCompact(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
return value.toFixed(0);
}
function formatThousands(value: number): string {
return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value / 1000);
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
function MultiSelect({ label, options, selected, onChange }: {
label: string;
options: string[];
selected: string[];
onChange: (v: string[]) => void;
}) {
const displayText = selected.length === 0
? "Не выбрано"
: selected.length <= 2
? selected.join(", ")
: `${selected.length} выбрано`;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 justify-between gap-1.5 min-w-[160px] max-w-[220px] text-xs font-normal"
>
<span className="truncate">{displayText}</span>
<Filter className="w-3 h-3 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2 max-h-[300px] overflow-y-auto" align="start">
<div className="text-xs font-medium text-muted-foreground mb-2">{label}</div>
{options.map((opt) => (
<label key={opt} className="flex items-center gap-2 py-1 px-1 hover:bg-muted/50 rounded cursor-pointer text-xs">
<Checkbox
checked={selected.includes(opt)}
onCheckedChange={(checked) => {
if (checked) onChange([...selected, opt]);
else onChange(selected.filter(s => s !== opt));
}}
/>
<span className="truncate">{opt}</span>
</label>
))}
{selected.length > 0 && (
<Button variant="ghost" size="sm" className="w-full mt-1 text-xs" onClick={() => onChange([])}>
Сбросить
</Button>
)}
</PopoverContent>
</Popover>
);
}
function HorizontalStackedChart({ title, data, height }: { title: string; data: any[]; height: number }) {
if (!data || data.length === 0) return null;
return (
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3">{title}</h3>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 10, right: 10 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" horizontal={false} />
<XAxis type="number" tickFormatter={formatCompact} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={140} />
<Tooltip
formatter={(value: number, name: string) => [formatCurrency(value), OVERDUE_LABELS[name] || name]}
labelFormatter={(label: string) => label}
/>
<Legend formatter={(value: string) => OVERDUE_LABELS[value] || value} wrapperStyle={{ fontSize: 10 }} />
{CLS_KEYS.map(key => (
<Bar key={key} dataKey={key} stackId="a" fill={OVERDUE_COLORS[key]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
function SimpleBarChart({ title, data, height }: { title: string; data: { cls: string; total: number }[]; height: number }) {
if (!data || data.length === 0) return null;
const clsColorMap: Record<string, string> = {
"Не просрочено": "#22c55e",
"1-30": "#3b82f6",
"31-60": "#eab308",
"61-90": "#f97316",
"91-365": "#ef4444",
"> 365": "#991b1b",
};
const chartData = data.map(d => ({
...d,
fill: clsColorMap[d.cls] || "#94a3b8",
}));
return (
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3">{title}</h3>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ left: 10, right: 10 }}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" horizontal={false} />
<XAxis type="number" tickFormatter={formatCompact} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="cls" tick={{ fontSize: 10 }} width={100} />
<Tooltip formatter={(value: number) => [formatCurrency(value), "Сумма"]} />
<Bar dataKey="total" radius={[0, 2, 2, 0]}>
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
function DebtTableSection({ analytics, page, setPage }: { analytics: DebtAnalytics; page: number; setPage: (p: number) => void }) {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const { table } = analytics;
return (
<Card>
<CardContent className="p-0">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-foreground">Таблица расшифровки ДЗ, млн. ₽</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="w-8 px-2"></th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Контрагент</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ОДЗ, ₽</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ПДЗ, ₽</th>
</tr>
</thead>
<tbody>
{table.rows.map((group) => {
const isExpanded = expandedRow === group.kontragent;
return (
<Fragment key={group.kontragent}>
<tr
className="border-b border-border cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => setExpandedRow(isExpanded ? null : group.kontragent)}
>
<td className="px-2 text-center">
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground inline" /> : <ChevronDown className="w-4 h-4 text-muted-foreground inline" />}
</td>
<td className="px-4 py-3 font-medium max-w-[250px]"><CopyableCell text={group.kontragent} className="max-w-[250px] font-medium" /></td>
<td className="px-4 py-3 text-right whitespace-nowrap font-semibold">{formatCurrency(group.totalDebt)}</td>
<td className="px-4 py-3 text-right whitespace-nowrap text-red-500 font-medium">{formatCurrency(group.totalOverdue)}</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={4} className="p-0">
<div className="bg-muted/20">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left px-8 py-2 text-xs font-medium text-muted-foreground">Договор</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Сумма Договора</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Документ</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Оплачено</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">ОДЗ, ₽</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">ПДЗ, ₽</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Отсрочка по договору</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Дней просрочки</th>
</tr>
</thead>
<tbody>
{group.rows.map((row, j) => (
<tr key={j} className="border-b border-border last:border-0">
<td className="px-8 py-2 text-muted-foreground text-xs max-w-[200px]"><CopyableCell text={row.dogovor || "—"} className="max-w-[200px]" /></td>
<td className="px-3 py-2 text-right whitespace-nowrap text-xs">{formatCurrency(row.summadokumenta)}</td>
<td className="px-3 py-2 text-muted-foreground text-xs max-w-[200px]"><CopyableCell text={row.dokumentraschetov || "—"} className="max-w-[200px]" /></td>
<td className="px-3 py-2 text-right whitespace-nowrap text-xs">{formatCurrency(row.summaoplacheno)}</td>
<td className="px-3 py-2 text-right whitespace-nowrap text-xs font-medium">{formatCurrency(row.summadolga)}</td>
<td className="px-3 py-2 text-right whitespace-nowrap text-xs text-red-500">{formatCurrency(row.summaprosrocheno)}</td>
<td className="px-3 py-2 text-right whitespace-nowrap text-xs text-muted-foreground">{row.otsrochkapodogovoru}</td>
<td className="px-3 py-2 text-right whitespace-nowrap text-xs text-muted-foreground">{Math.round(row.dneiprosrocheno)}</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
{table.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-xs text-muted-foreground">
Стр. {table.page} из {table.totalPages} ({table.total} контрагентов)
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" disabled={page >= table.totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
function DZTab() {
const [period, setPeriod] = useState("");
const [selectedKlasspy, setSelectedKlasspy] = useState<string[]>([]);
const [selectedKontragent, setSelectedKontragent] = useState<string[]>([]);
const [dogovorSearch, setDogovorSearch] = useState("");
const [selectedManager, setSelectedManager] = useState<string[]>([]);
const [page, setPage] = useState(1);
const analyticsParams = useMemo(() => ({
period: period || undefined,
klassprosrochki: selectedKlasspy,
kontragent: selectedKontragent,
dogovor: dogovorSearch || undefined,
otvetstvennii: selectedManager,
page,
pageSize: 20,
}), [period, selectedKlasspy, selectedKontragent, dogovorSearch, selectedManager, page]);
const { data: analytics, isLoading, isError } = useDebtAnalytics(analyticsParams);
const { data: filters } = useDebtFilters();
const hasFilters = period || selectedKlasspy.length > 0 || selectedKontragent.length > 0 || dogovorSearch || selectedManager.length > 0;
function resetFilters() {
setPeriod("");
setSelectedKlasspy([]);
setSelectedKontragent([]);
setDogovorSearch("");
setSelectedManager([]);
setPage(1);
}
const chartHeight = useMemo(() => {
if (!analytics) return 250;
const maxItems = Math.max(analytics.byKontragent.length, analytics.byManager.length, analytics.byOverdueClass.length);
return Math.max(200, maxItems * 35 + 60);
}, [analytics]);
return (
<div className="space-y-5">
{isError && <DatabaseError />}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{isLoading ? (
<>
{[1, 2, 3].map(i => (
<Card key={i}><CardContent className="p-4"><Skeleton className="h-4 w-32 mb-2" /><Skeleton className="h-7 w-24" /></CardContent></Card>
))}
</>
) : analytics?.summary && (
<>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Clock className="w-3.5 h-3.5" />
Дата и время обновления
</div>
<div className="text-lg font-bold text-primary">
{analytics.summary.updatedAt ? formatDateTime(analytics.summary.updatedAt) : "—"}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1">Общая сумма ДЗ, тыс. ₽</p>
<p className="text-lg font-bold text-foreground">
{formatThousands(analytics.summary.totalDebt)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1">Общая сумма ПДЗ, тыс. ₽</p>
<p className="text-lg font-bold text-red-500">
{formatThousands(analytics.summary.totalOverdue)}
</p>
</CardContent>
</Card>
</>
)}
</div>
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Период</Label>
<Input
type="date"
value={period}
onChange={e => { setPeriod(e.target.value); setPage(1); }}
className="h-9 text-xs w-[150px]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Класс просрочки</Label>
<MultiSelect
label="Класс просрочки"
options={filters?.filters?.klassprosrochki || []}
selected={selectedKlasspy}
onChange={v => { setSelectedKlasspy(v); setPage(1); }}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Контрагент</Label>
<MultiSelect
label="Контрагент"
options={filters?.filters?.kontragenty || []}
selected={selectedKontragent}
onChange={v => { setSelectedKontragent(v); setPage(1); }}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Договор</Label>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
value={dogovorSearch}
onChange={e => { setDogovorSearch(e.target.value); setPage(1); }}
placeholder="Поиск..."
className="h-9 text-xs pl-8 w-[150px]"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Менеджер</Label>
<MultiSelect
label="Менеджер"
options={filters?.filters?.otvetstvennii || []}
selected={selectedManager}
onChange={v => { setSelectedManager(v); setPage(1); }}
/>
</div>
{hasFilters && (
<Button variant="outline" size="sm" onClick={resetFilters} className="h-9">
<RotateCcw className="w-3.5 h-3.5 mr-1" />
Сбросить
</Button>
)}
</div>
</CardContent>
</Card>
{isLoading ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<Card key={i}><CardContent className="p-4"><Skeleton className="h-[250px] w-full" /></CardContent></Card>
))}
</div>
) : analytics && (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HorizontalStackedChart title="ДЗ по Контрагентам, млн. ₽" data={analytics.byKontragent} height={chartHeight} />
<HorizontalStackedChart title="ДЗ по Менеджерам, млн. ₽" data={analytics.byManager} height={chartHeight} />
<SimpleBarChart title="ДЗ по сроку просрочки, млн. ₽" data={analytics.byOverdueClass} height={chartHeight} />
</div>
<DebtTableSection analytics={analytics} page={page} setPage={setPage} />
</>
)}
</div>
);
}
function DynamicsTab() {
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [kontragent, setKontragent] = useState("");
const { data: filters } = useDebtFilters();
const { data: dynamicsData, isLoading: dynamicsLoading } = useDebtDynamics(dateFrom || undefined, dateTo || undefined, kontragent || undefined);
const { data: kontragentData } = useDebtKontragenty(undefined, dateTo || undefined);
const chartData = useMemo(() => {
if (!dynamicsData?.rows) return [];
return dynamicsData.rows.map(d => ({
period: new Date(d.period).toLocaleDateString("ru-RU", { day: "2-digit", month: "short", year: "2-digit" }),
dz: d.totalDebt,
pdz: d.totalOverdue,
}));
}, [dynamicsData]);
const latestValues = useMemo(() => {
if (!dynamicsData?.rows || dynamicsData.rows.length === 0) return { dz: 0, pdz: 0 };
const last = dynamicsData.rows[dynamicsData.rows.length - 1];
return { dz: last.totalDebt, pdz: last.totalOverdue };
}, [dynamicsData]);
return (
<div className="space-y-5">
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Период (Динамика)</Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="h-9 text-xs w-[150px]"
/>
<span className="text-xs text-muted-foreground">—</span>
<Input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="h-9 text-xs w-[150px]"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Контрагент</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-9 min-w-[200px] justify-between text-xs font-normal">
<span className="truncate">{kontragent || "Не выбрано"}</span>
<Filter className="w-3 h-3 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2 max-h-[300px] overflow-y-auto" align="start">
<div
className="py-1 px-2 hover:bg-muted/50 rounded cursor-pointer text-xs text-muted-foreground"
onClick={() => setKontragent("")}
>
Все контрагенты
</div>
{(filters?.filters?.kontragenty || []).map((k) => (
<div
key={k}
className={`py-1 px-2 hover:bg-muted/50 rounded cursor-pointer text-xs ${kontragent === k ? "bg-primary/10 text-primary font-medium" : ""}`}
onClick={() => setKontragent(k)}
>
{k}
</div>
))}
</PopoverContent>
</Popover>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="text-sm font-medium text-foreground">Динамика ДЗ и ПДЗ, млн. ₽</h3>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-0.5 bg-blue-500 rounded"></div>
<span>ДЗ</span>
<span className="font-bold text-blue-500">{formatCurrency(latestValues.dz)}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-0.5 bg-red-400 rounded"></div>
<span>ПДЗ</span>
<span className="font-bold text-red-400">{formatCurrency(latestValues.pdz)}</span>
</div>
</div>
</div>
{dynamicsLoading ? (
<Skeleton className="h-[350px] w-full" />
) : (
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tickFormatter={formatCompact} tick={{ fontSize: 10 }} />
<Tooltip
formatter={(value: number, name: string) => [
formatCurrency(value),
name === "dz" ? "ДЗ" : "ПДЗ"
]}
labelFormatter={(label: string) => label}
/>
<Legend formatter={(value: string) => value === "dz" ? "ДЗ" : "ПДЗ"} />
<Line type="monotone" dataKey="dz" stroke="#3b82f6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="pdz" stroke="#f87171" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{kontragentData?.rows && kontragentData.rows.length > 0 && (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Контрагент</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">Сумма Договора</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">Оплачено</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ОДЗ Руб.</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ПДЗ Руб.</th>
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">Дней просрочки</th>
</tr>
</thead>
<tbody>
{kontragentData.rows.map((row, i) => (
<tr key={i} className="border-b border-border last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 max-w-[250px] font-medium"><CopyableCell text={row.kontragent} className="max-w-[250px] font-medium" /></td>
<td className="px-4 py-3 text-right whitespace-nowrap">{formatCurrency(row.summadokumenta)}</td>
<td className="px-4 py-3 text-right whitespace-nowrap text-muted-foreground">{formatCurrency(row.summaoplacheno)}</td>
<td className="px-4 py-3 text-right whitespace-nowrap font-medium">{formatCurrency(row.summadolga)}</td>
<td className="px-4 py-3 text-right whitespace-nowrap text-red-500">{formatCurrency(row.summaprosrocheno)}</td>
<td className="px-4 py-3 text-right whitespace-nowrap text-muted-foreground">{formatNumber(Math.round(row.dneiprosrocheno))}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
export default function DebtPage() {
return (
<FinanceLayout>
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Дебиторская задолженность</h1>
<p className="text-muted-foreground text-sm mt-0.5">Анализ дебиторской задолженности по контрагентам, менеджерам и срокам просрочки</p>
</div>
<FinanceNav active="debt" onChange={() => {}} />
<Tabs defaultValue="dz">
<TabsList>
<TabsTrigger value="dz">ДЗ</TabsTrigger>
<TabsTrigger value="dynamics">Динамика</TabsTrigger>
</TabsList>
<TabsContent value="dz" className="mt-4">
<DZTab />
</TabsContent>
<TabsContent value="dynamics" className="mt-4">
<DynamicsTab />
</TabsContent>
</Tabs>
</div>
</FinanceLayout>
);
}

View File

@@ -5,8 +5,11 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { MultiSelectPopover } from "@/components/MultiSelectPopover";
import { useToast } from "@/hooks/use-toast";
import { formatCurrency } from "@/lib/utils";
import {
useNeof,
@@ -16,6 +19,12 @@ import {
useDeleteZofAssign,
useSalaryDbStatus,
useSalaryMonthly,
useGoogleStatus,
useGoogleAuthUrl,
useSalarySync,
useSalaryAreas,
useCreateSalaryArea,
useDeleteSalaryArea,
} from "@/hooks/useFinance";
import {
Banknote,
@@ -29,6 +38,10 @@ import {
X,
RefreshCw,
Database,
LogIn,
ExternalLink,
Trash2,
Plus,
} from "lucide-react";
import {
BarChart,
@@ -771,6 +784,222 @@ function AnalyticsTab() {
);
}
// =====================
// Google Sheets source
// =====================
function GoogleSourceSection() {
const { toast } = useToast();
const { data: googleStatus, isLoading: statusLoading } = useGoogleStatus();
const { refetch: refetchAuthUrl } = useGoogleAuthUrl();
const syncMutation = useSalarySync();
const { data: areasData, isLoading: areasLoading } = useSalaryAreas();
const createArea = useCreateSalaryArea();
const deleteArea = useDeleteSalaryArea();
const [newName, setNewName] = useState("");
const [newUrl, setNewUrl] = useState("");
const [newFioCol, setNewFioCol] = useState("B");
const [newBazaCol, setNewBazaCol] = useState("F");
const [newKVydacheCol, setNewKVydacheCol] = useState("J");
const loggedIn = googleStatus?.loggedIn === true;
const areas = areasData?.areas ?? [];
const handleLogin = async () => {
try {
const res = await refetchAuthUrl();
const url = res.data?.url;
if (url) {
window.open(url, "_blank");
} else {
toast({ title: "Не удалось получить ссылку авторизации", variant: "destructive" });
}
} catch {
toast({ title: "Не удалось получить ссылку авторизации", variant: "destructive" });
}
};
const handleSync = () => {
syncMutation.mutate(undefined, {
onSuccess: (data) => {
if (data.authRequired) {
toast({ title: "Требуется авторизация Google", variant: "destructive" });
return;
}
let description = `Добавлено: ${data.added}, обновлено: ${data.updated}, удалено: ${data.deleted}`;
if (data.errors?.length) description += `. Ошибок: ${data.errors.length}`;
toast({ title: "Синхронизация завершена", description });
},
onError: (err) => {
toast({ title: "Ошибка синхронизации", description: err.message, variant: "destructive" });
},
});
};
const handleAddArea = () => {
if (!newName.trim() || !newUrl.trim()) {
toast({ title: "Заполните название и ссылку на таблицу", variant: "destructive" });
return;
}
createArea.mutate(
{
name: newName.trim(),
spreadsheetUrl: newUrl.trim(),
fioCol: newFioCol.trim().toUpperCase() || undefined,
bazaCol: newBazaCol.trim().toUpperCase() || undefined,
kVydacheCol: newKVydacheCol.trim().toUpperCase() || undefined,
},
{
onSuccess: () => {
setNewName("");
setNewUrl("");
setNewFioCol("B");
setNewBazaCol("F");
setNewKVydacheCol("J");
},
}
);
};
return (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm">Источник данных (Google Sheets)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{statusLoading ? (
<Skeleton className="h-8 w-48" />
) : loggedIn ? (
<Badge variant="outline" className="text-xs gap-1.5 py-1">
<span className="w-2 h-2 rounded-full bg-green-500" />
Авторизован: {googleStatus?.email || "Google"}
</Badge>
) : (
<Badge variant="outline" className="text-xs gap-1.5 py-1 text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-muted-foreground/50" />
Google не авторизован
</Badge>
)}
<div className="flex items-center gap-2 ml-auto">
{!loggedIn && (
<Button size="sm" variant="outline" className="h-8 text-xs gap-1.5" onClick={handleLogin}>
<LogIn className="w-3.5 h-3.5" />
Войти через Google
</Button>
)}
<Button
size="sm"
className="h-8 text-xs gap-1.5"
onClick={handleSync}
disabled={syncMutation.isPending || !loggedIn}
>
{syncMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Синхронизировать
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Участки ({areas.length})</p>
{areasLoading ? (
<div className="space-y-2">
{[1, 2].map(i => <Skeleton key={i} className="h-14 rounded-lg" />)}
</div>
) : areas.length === 0 ? (
<p className="text-sm text-muted-foreground">Участки не добавлены</p>
) : (
areas.map(area => (
<div key={area.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{area.name}</p>
<p className="text-xs text-muted-foreground truncate">
ID: {area.spreadsheetId || "—"}
{area.fioCol || area.bazaCol || area.kVydacheCol
? ` · Столбцы: ФИО=${area.fioCol || "B"}, База=${area.bazaCol || "F"}, К выдаче=${area.kVydacheCol || "J"}`
: ""}
</p>
{area.spreadsheetUrl && (
<a
href={area.spreadsheetUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline inline-flex items-center gap-1 mt-0.5"
>
<ExternalLink className="w-3 h-3" />
Открыть таблицу
</a>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => deleteArea.mutate(area.id)}
disabled={deleteArea.isPending}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
<div className="rounded-lg border p-3 space-y-3">
<p className="text-sm font-medium">Добавить участок</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Название участка</Label>
<Input
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Например: Производство"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Ссылка на Google Таблицу</Label>
<Input
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
placeholder="https://docs.google.com/spreadsheets/d/..."
className="h-8 text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Столбцы (буква, опционально)</Label>
<div className="grid grid-cols-3 gap-2 max-w-xs">
<div>
<Label className="text-xs text-muted-foreground">ФИО</Label>
<Input value={newFioCol} onChange={e => setNewFioCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} />
</div>
<div>
<Label className="text-xs text-muted-foreground">База</Label>
<Input value={newBazaCol} onChange={e => setNewBazaCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} />
</div>
<div>
<Label className="text-xs text-muted-foreground">К выдаче</Label>
<Input value={newKVydacheCol} onChange={e => setNewKVydacheCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} />
</div>
</div>
</div>
<Button
size="sm"
className="gap-1.5"
onClick={handleAddArea}
disabled={createArea.isPending || !newName.trim() || !newUrl.trim()}
>
<Plus className="w-3.5 h-3.5" />
Добавить
</Button>
</div>
</CardContent>
</Card>
);
}
// =====================
// Main Page
// =====================
@@ -794,6 +1023,8 @@ export default function SalaryPage() {
<p className="text-xs text-muted-foreground mt-0.5">Данные из PostgreSQL</p>
</div>
<GoogleSourceSection />
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className="h-8" data-testid="salary-tabs">
<TabsTrigger value="of" className="text-xs h-7 px-3" data-testid="tab-of">ОФ</TabsTrigger>