278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
import { useState } from "react";
|
||
import { useLocation, useSearch } from "wouter";
|
||
import { FinanceLayout } from "@/components/finance/FinanceLayout";
|
||
import { FinanceNav, type FinanceTab } from "@/components/finance/FinanceNav";
|
||
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
|
||
import { ConnectionStatus } from "@/components/finance/ConnectionStatus";
|
||
import { DateRangeFilter, type DateRange as LocalDateRange } from "@/components/finance/DateRangeFilter";
|
||
import { SummaryCards } from "@/components/finance/SummaryCards";
|
||
import { DataTable } from "@/components/finance/DataTable";
|
||
import { CategoryTree } from "@/components/finance/CategoryTree";
|
||
import { CategoryManager } from "@/components/finance/CategoryManager";
|
||
import { FinanceSettingsPanel } from "@/components/finance/FinanceSettings";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Loader2, RefreshCw } from "lucide-react";
|
||
import {
|
||
useIncome,
|
||
useExpenses,
|
||
useIncomeTreeSummary,
|
||
useExpenseTreeSummary,
|
||
useIncomeCategories,
|
||
useExpenseCategories,
|
||
useCreateIncomeCategory,
|
||
useUpdateIncomeCategory,
|
||
useDeleteIncomeCategory,
|
||
useCreateExpenseCategory,
|
||
useUpdateExpenseCategory,
|
||
useDeleteExpenseCategory,
|
||
useTableConfig,
|
||
useRefreshAll,
|
||
} from "@/hooks/useFinance";
|
||
|
||
function toISODate(d?: Date): string | undefined {
|
||
if (!d) return undefined;
|
||
return d.toISOString().split("T")[0];
|
||
}
|
||
|
||
function tabFromSearch(search: string): FinanceTab | null {
|
||
const tab = new URLSearchParams(search).get("tab");
|
||
const valid: FinanceTab[] = [
|
||
"dashboard",
|
||
"income",
|
||
"expenses",
|
||
"income-categories",
|
||
"expense-categories",
|
||
"settings",
|
||
];
|
||
return valid.includes(tab as FinanceTab) ? (tab as FinanceTab) : null;
|
||
}
|
||
|
||
export default function FinanceDashboard() {
|
||
const [search] = useSearch();
|
||
const [, navigate] = useLocation();
|
||
const [activeTab, setActiveTabState] = useState<FinanceTab>(() => tabFromSearch(search) ?? "dashboard");
|
||
|
||
const setActiveTab = (tab: FinanceTab) => {
|
||
setActiveTabState(tab);
|
||
navigate(tab === "dashboard" ? "/finance" : `/finance?tab=${tab}`, { replace: true });
|
||
};
|
||
const [dateRange, setDateRange] = useState<LocalDateRange>({});
|
||
const [incomePage, setIncomePage] = useState(1);
|
||
const [expensePage, setExpensePage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(50);
|
||
const [incomeSort, setIncomeSort] = useState({ by: "data", dir: "desc" as "asc" | "desc" });
|
||
const [expenseSort, setExpenseSort] = useState({ by: "data", dir: "desc" as "asc" | "desc" });
|
||
const [incomeFilters, setIncomeFilters] = useState<Record<string, string>>({});
|
||
const [expenseFilters, setExpenseFilters] = useState<Record<string, string>>({});
|
||
|
||
const dr = { from: toISODate(dateRange.from), to: toISODate(dateRange.to) };
|
||
|
||
const { data: incomeData, isLoading: incomeLoading } = useIncome(
|
||
incomePage, pageSize, incomeSort.by, incomeSort.dir, incomeFilters, dr
|
||
);
|
||
const { data: expenseData, isLoading: expenseLoading } = useExpenses(
|
||
expensePage, pageSize, expenseSort.by, expenseSort.dir, expenseFilters, dr
|
||
);
|
||
const { data: incomeTree, isLoading: incomeTreeLoading, dataUpdatedAt: incomeTreeUpdatedAt } = useIncomeTreeSummary(dr);
|
||
const { data: expenseTree, isLoading: expenseTreeLoading } = useExpenseTreeSummary(dr);
|
||
const { data: incomeCats } = useIncomeCategories();
|
||
const { data: expenseCats } = useExpenseCategories();
|
||
const { data: tableConfig } = useTableConfig();
|
||
const refreshAll = useRefreshAll();
|
||
|
||
const createIncCat = useCreateIncomeCategory();
|
||
const updateIncCat = useUpdateIncomeCategory();
|
||
const deleteIncCat = useDeleteIncomeCategory();
|
||
const createExpCat = useCreateExpenseCategory();
|
||
const updateExpCat = useUpdateExpenseCategory();
|
||
const deleteExpCat = useDeleteExpenseCategory();
|
||
|
||
const incomeTotal = incomeTree?.grandTotal?.total ?? 0;
|
||
const expenseTotal = expenseTree?.grandTotal?.total ?? 0;
|
||
const incomeCount = incomeTree?.grandTotal?.count ?? 0;
|
||
const expenseCount = expenseTree?.grandTotal?.count ?? 0;
|
||
|
||
const columnLabels = tableConfig?.config?.columnLabels ?? {};
|
||
const incomeSumCol = tableConfig?.config?.incomeSumColumn ?? "summa";
|
||
const expenseSumCol = tableConfig?.config?.expenseSumColumn ?? "summadokumenta";
|
||
|
||
const handleIncomeSort = (col: string) => {
|
||
setIncomeSort((s) => ({
|
||
by: col,
|
||
dir: s.by === col && s.dir === "desc" ? "asc" : "desc",
|
||
}));
|
||
};
|
||
|
||
const handleExpenseSort = (col: string) => {
|
||
setExpenseSort((s) => ({
|
||
by: col,
|
||
dir: s.by === col && s.dir === "desc" ? "asc" : "desc",
|
||
}));
|
||
};
|
||
|
||
const visibleIncomeCols = ["data", "summa", "zakazchik", "dogovorzakazchika", "statya", "kommentarii"];
|
||
const visibleExpenseCols = ["data", "summadokumenta", "kontragent", "dogovorkontragenta", "statya", "kommentarii"];
|
||
|
||
return (
|
||
<FinanceLayout
|
||
mobileNav={<FinanceBottomNav active={activeTab} onChange={setActiveTab} />}
|
||
>
|
||
<div className="space-y-4">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-xl font-bold">Финансы</h1>
|
||
<div className="mt-1">
|
||
<ConnectionStatus />
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<DateRangeFilter value={dateRange} onChange={(r) => { setDateRange(r); setIncomePage(1); setExpensePage(1); }} />
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-8 text-xs"
|
||
onClick={() => refreshAll.mutate()}
|
||
disabled={refreshAll.isPending}
|
||
>
|
||
{refreshAll.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||
</Button>
|
||
{incomeTreeUpdatedAt && (
|
||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||
Обновлено: {new Date(incomeTreeUpdatedAt).toLocaleTimeString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<FinanceNav active={activeTab} onChange={setActiveTab} />
|
||
|
||
{/* Dashboard */}
|
||
{activeTab === "dashboard" && (
|
||
<div className="space-y-4">
|
||
<SummaryCards
|
||
incomeTotal={incomeTotal}
|
||
expenseTotal={expenseTotal}
|
||
incomeCount={incomeCount}
|
||
expenseCount={expenseCount}
|
||
loading={incomeTreeLoading || expenseTreeLoading}
|
||
/>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<div>
|
||
<h3 className="text-sm font-semibold mb-2">Доходы по категориям</h3>
|
||
{incomeTreeLoading ? (
|
||
<div className="text-xs text-muted-foreground py-4">Загрузка...</div>
|
||
) : (
|
||
<CategoryTree
|
||
trees={incomeTree?.trees ?? []}
|
||
uncategorized={incomeTree?.uncategorized}
|
||
storageKey="omegaprom_finance_income_expanded"
|
||
onSelect={(id) => {
|
||
setActiveTab("income");
|
||
// could filter by category here in future
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<h3 className="text-sm font-semibold mb-2">Расходы по категориям</h3>
|
||
{expenseTreeLoading ? (
|
||
<div className="text-xs text-muted-foreground py-4">Загрузка...</div>
|
||
) : (
|
||
<CategoryTree
|
||
trees={expenseTree?.trees ?? []}
|
||
uncategorized={expenseTree?.uncategorized}
|
||
storageKey="omegaprom_finance_expense_expanded"
|
||
onSelect={(id) => {
|
||
setActiveTab("expenses");
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Income */}
|
||
{activeTab === "income" && (
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold">Доходы</h3>
|
||
<DataTable
|
||
data={incomeData}
|
||
loading={incomeLoading}
|
||
columnLabels={columnLabels}
|
||
sumColumn={incomeSumCol}
|
||
visibleColumns={visibleIncomeCols}
|
||
sortBy={incomeSort.by}
|
||
sortDir={incomeSort.dir}
|
||
onSort={handleIncomeSort}
|
||
filters={incomeFilters}
|
||
onFilterChange={(col, val) => {
|
||
setIncomeFilters((f) => ({ ...f, [col]: val }));
|
||
setIncomePage(1);
|
||
}}
|
||
page={incomePage}
|
||
onPageChange={setIncomePage}
|
||
pageSize={pageSize}
|
||
onPageSizeChange={setPageSize}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Expenses */}
|
||
{activeTab === "expenses" && (
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold">Расходы</h3>
|
||
<DataTable
|
||
data={expenseData}
|
||
loading={expenseLoading}
|
||
columnLabels={columnLabels}
|
||
sumColumn={expenseSumCol}
|
||
visibleColumns={visibleExpenseCols}
|
||
sortBy={expenseSort.by}
|
||
sortDir={expenseSort.dir}
|
||
onSort={handleExpenseSort}
|
||
filters={expenseFilters}
|
||
onFilterChange={(col, val) => {
|
||
setExpenseFilters((f) => ({ ...f, [col]: val }));
|
||
setExpensePage(1);
|
||
}}
|
||
page={expensePage}
|
||
onPageChange={setExpensePage}
|
||
pageSize={pageSize}
|
||
onPageSizeChange={setPageSize}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Income Categories */}
|
||
{activeTab === "income-categories" && (
|
||
<CategoryManager
|
||
categories={incomeCats?.categories ?? []}
|
||
onCreate={(c) => createIncCat.mutate(c)}
|
||
onUpdate={(id, c) => updateIncCat.mutate({ id, ...c })}
|
||
onDelete={(id) => deleteIncCat.mutate(id)}
|
||
loading={!incomeCats}
|
||
typeLabel="Категории доходов"
|
||
/>
|
||
)}
|
||
|
||
{/* Expense Categories */}
|
||
{activeTab === "expense-categories" && (
|
||
<CategoryManager
|
||
categories={expenseCats?.categories ?? []}
|
||
onCreate={(c) => createExpCat.mutate(c)}
|
||
onUpdate={(id, c) => updateExpCat.mutate({ id, ...c })}
|
||
onDelete={(id) => deleteExpCat.mutate(id)}
|
||
loading={!expenseCats}
|
||
typeLabel="Категории расходов"
|
||
/>
|
||
)}
|
||
|
||
{/* Settings */}
|
||
{activeTab === "settings" && <FinanceSettingsPanel />}
|
||
</div>
|
||
</FinanceLayout>
|
||
);
|
||
}
|