Финансы: дебиторка (vw_debt_active), расчёты НДС/налога на прибыль, аудит целостности с автопланировщиком, синхронизация зарплат из Google Sheets, миграция настроек Data-Insight2
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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" /> },
|
||||
];
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
278
client/src/pages/finance/audit.tsx
Normal file
278
client/src/pages/finance/audit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1171
client/src/pages/finance/calculations.tsx
Normal file
1171
client/src/pages/finance/calculations.tsx
Normal file
File diff suppressed because it is too large
Load Diff
651
client/src/pages/finance/debt.tsx
Normal file
651
client/src/pages/finance/debt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user