Финансы: мобильная нижняя навигация (Обзор / Разделы / Настройки) вместо боковой панели на телефоне

This commit is contained in:
2026-09-05 17:59:34 +03:00
parent ccfc396d40
commit 4b34a4bea5
15 changed files with 215 additions and 17 deletions

View File

@@ -0,0 +1,135 @@
import { Link, useLocation } from "wouter";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Wallet,
Calculator,
ShieldCheck,
Tags,
TrendingUp,
TrendingDown,
Settings,
Menu,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { FinanceTab } from "./FinanceNav";
interface FinanceBottomNavProps {
active: FinanceTab;
onChange: (tab: FinanceTab) => void;
}
export function FinanceBottomNav({ active, onChange }: FinanceBottomNavProps) {
const [location, setLocation] = useLocation();
// путь в wouter может включать search-параметр
const onDashboard = location.split("?")[0] === "/finance";
const goDashboardTab = (tab: FinanceTab) => {
if (onDashboard) {
onChange(tab);
} else {
setLocation(tab === "dashboard" ? "/finance" : `/finance?tab=${tab}`);
}
};
const btnClass = (isActive: boolean) =>
cn(
"flex flex-col items-center justify-center gap-0.5 flex-1 h-full rounded-lg transition-colors",
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground"
);
const sectionsActive =
active === "debt" ||
active === "calculations" ||
active === "audit" ||
active === "income" ||
active === "expenses" ||
active === "income-categories" ||
active === "expense-categories";
return (
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border z-50 md:hidden safe-area-bottom">
<nav className="flex items-stretch justify-around h-14 px-2" aria-label="Навигация по финансам">
<button
onClick={() => goDashboardTab("dashboard")}
aria-label="Обзор"
className={btnClass(active === "dashboard")}
>
<LayoutDashboard className="w-5 h-5" aria-hidden="true" />
<span className="text-[10px] font-medium">Обзор</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label="Разделы"
className={cn(btnClass(false), "relative", sectionsActive && "text-primary")}
>
<Menu className="w-5 h-5" aria-hidden="true" />
<span className="text-[10px] font-medium">Разделы</span>
{sectionsActive && (
<span
aria-hidden="true"
className="absolute top-1 right-1/2 translate-x-4 w-1.5 h-1.5 rounded-full bg-primary"
/>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" className="w-56 mb-2">
<DropdownMenuItem className="text-xs cursor-pointer" onSelect={() => goDashboardTab("income")}>
<TrendingUp className="w-3.5 h-3.5 mr-2" />
Доходы
</DropdownMenuItem>
<DropdownMenuItem className="text-xs cursor-pointer" onSelect={() => goDashboardTab("expenses")}>
<TrendingDown className="w-3.5 h-3.5 mr-2" />
Расходы
</DropdownMenuItem>
<DropdownMenuSeparator />
<Link href="/finance/debt">
<DropdownMenuItem className="text-xs cursor-pointer">
<Wallet className="w-3.5 h-3.5 mr-2" />
Дебиторка
</DropdownMenuItem>
</Link>
<Link href="/finance/calculations">
<DropdownMenuItem className="text-xs cursor-pointer">
<Calculator className="w-3.5 h-3.5 mr-2" />
Расчёты
</DropdownMenuItem>
</Link>
<Link href="/finance/audit">
<DropdownMenuItem className="text-xs cursor-pointer">
<ShieldCheck className="w-3.5 h-3.5 mr-2" />
Аудит
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-xs cursor-pointer" onSelect={() => goDashboardTab("income-categories")}>
<Tags className="w-3.5 h-3.5 mr-2" />
Категории доходов
</DropdownMenuItem>
<DropdownMenuItem className="text-xs cursor-pointer" onSelect={() => goDashboardTab("expense-categories")}>
<Tags className="w-3.5 h-3.5 mr-2" />
Категории расходов
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href="/finance/settings"
aria-label="Настройки"
className={btnClass(active === "settings")}
>
<Settings className="w-5 h-5" aria-hidden="true" />
<span className="text-[10px] font-medium">Настройки</span>
</Link>
</nav>
</div>
);
}

View File

@@ -17,7 +17,7 @@ export function FinanceHeader({ showBack, onToggleSidebar }: FinanceHeaderProps)
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-7 w-7 hidden md:inline-flex"
onClick={onToggleSidebar}
title="Сайдбар"
data-testid="header-sidebar-toggle"

View File

@@ -5,23 +5,28 @@ import { FinanceHeader } from "./FinanceHeader";
interface FinanceLayoutProps {
children: React.ReactNode;
showBack?: boolean;
/** Мобильная нижняя навигация (показывается только на телефонах) */
mobileNav?: React.ReactNode;
}
export function FinanceLayout({ children, showBack }: FinanceLayoutProps) {
export function FinanceLayout({ children, showBack, mobileNav }: FinanceLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
return (
<div className="flex h-screen w-screen overflow-hidden bg-background">
<FinanceSidebar collapsed={sidebarCollapsed} />
<div className="hidden md:block">
<FinanceSidebar collapsed={sidebarCollapsed} />
</div>
<div className="flex flex-col flex-1 min-w-0">
<FinanceHeader
showBack={showBack}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
/>
<main className="flex-1 overflow-y-auto p-4">
<main className="flex-1 overflow-y-auto p-4 pb-24 md:pb-4">
{children}
</main>
</div>
{mobileNav}
</div>
);
}

View File

@@ -36,7 +36,7 @@ const items: { key: FinanceTab; label: string; icon: React.ReactNode; href?: str
export function FinanceNav({ active, onChange }: FinanceNavProps) {
return (
<div className="flex items-center gap-1 border-b border-border pb-1 overflow-x-auto">
<div className="hidden md:flex items-center gap-1 border-b border-border pb-1 overflow-x-auto">
{items.map((item) => {
const isActive = active === item.key;
const className = cn(

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceNav } from "@/components/finance/FinanceNav";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -210,7 +211,9 @@ export default function AuditPage() {
const running = statusData?.running || runAudit.isPending;
return (
<FinanceLayout>
<FinanceLayout
mobileNav={<FinanceBottomNav active="audit" onChange={() => {}} />}
>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect } from "react";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceNav } from "@/components/finance/FinanceNav";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { DatabaseError } from "@/components/finance/DatabaseError";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -1087,7 +1088,9 @@ export default function CalculationsPage() {
const isError = activeTab === "vat" ? vatQuery.isError : profitTax.isError;
return (
<FinanceLayout>
<FinanceLayout
mobileNav={<FinanceBottomNav active="calculations" onChange={() => {}} />}
>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>

View File

@@ -1,6 +1,8 @@
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";
@@ -32,8 +34,28 @@ function toISODate(d?: Date): string | 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 [activeTab, setActiveTab] = useState<FinanceTab>("dashboard");
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);
@@ -92,7 +114,9 @@ export default function FinanceDashboard() {
const visibleExpenseCols = ["data", "summadokumenta", "kontragent", "dogovorkontragenta", "statya", "kommentarii"];
return (
<FinanceLayout>
<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">

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, Fragment } from "react";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceNav } from "@/components/finance/FinanceNav";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { CopyableCell } from "@/components/finance/CopyableCell";
import { DatabaseError } from "@/components/finance/DatabaseError";
import { Card, CardContent } from "@/components/ui/card";
@@ -622,7 +623,9 @@ function DynamicsTab() {
export default function DebtPage() {
return (
<FinanceLayout>
<FinanceLayout
mobileNav={<FinanceBottomNav active="debt" onChange={() => {}} />}
>
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Дебиторская задолженность</h1>

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -90,7 +91,10 @@ export default function ExpenseCategoriesPage() {
};
return (
<FinanceLayout showBack>
<FinanceLayout
showBack
mobileNav={<FinanceBottomNav active="expense-categories" onChange={() => {}} />}
>
<div className="space-y-6">
{/* Categories Tree */}
<div className="space-y-3">

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect } from "react";
import { useLocation } from "wouter";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { TreeNodeRow } from "@/components/finance/TreeNodeRow";
import { AnalyticsChart } from "@/components/finance/AnalyticsChart";
import { ColumnFilter } from "@/components/finance/ColumnFilter";
@@ -73,7 +74,9 @@ export default function ExpensesOverviewPage() {
};
return (
<FinanceLayout>
<FinanceLayout
mobileNav={<FinanceBottomNav active="expenses" onChange={() => {}} />}
>
<div className="space-y-4">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useRoute } from "wouter";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -72,7 +73,10 @@ export default function ExpensesDetailPage() {
const activeFilters = Object.entries(filters).filter(([, v]) => v);
return (
<FinanceLayout showBack>
<FinanceLayout
showBack
mobileNav={<FinanceBottomNav active="expenses" onChange={() => {}} />}
>
<div className="space-y-3 h-full flex flex-col overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -90,7 +91,10 @@ export default function IncomeCategoriesPage() {
};
return (
<FinanceLayout showBack>
<FinanceLayout
showBack
mobileNav={<FinanceBottomNav active="income-categories" onChange={() => {}} />}
>
<div className="space-y-6">
{/* Categories Tree */}
<div className="space-y-3">

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect } from "react";
import { useLocation } from "wouter";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { TreeNodeRow } from "@/components/finance/TreeNodeRow";
import { AnalyticsChart } from "@/components/finance/AnalyticsChart";
import { ColumnFilter } from "@/components/finance/ColumnFilter";
@@ -73,7 +74,9 @@ export default function IncomeOverviewPage() {
};
return (
<FinanceLayout>
<FinanceLayout
mobileNav={<FinanceBottomNav active="income" onChange={() => {}} />}
>
<div className="space-y-4">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useRoute } from "wouter";
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -72,7 +73,10 @@ export default function IncomeDetailPage() {
const activeFilters = Object.entries(filters).filter(([, v]) => v);
return (
<FinanceLayout showBack>
<FinanceLayout
showBack
mobileNav={<FinanceBottomNav active="income" onChange={() => {}} />}
>
<div className="space-y-3 h-full flex flex-col overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>

View File

@@ -1,9 +1,12 @@
import { FinanceLayout } from "@/components/finance/FinanceLayout";
import { FinanceSettingsPanel } from "@/components/finance/FinanceSettings";
import { FinanceBottomNav } from "@/components/finance/FinanceBottomNav";
export default function FinanceSettingsPage() {
return (
<FinanceLayout>
<FinanceLayout
mobileNav={<FinanceBottomNav active="settings" onChange={() => {}} />}
>
<div className="space-y-4">
<h1 className="text-xl font-bold">Настройки</h1>
<FinanceSettingsPanel />