Финансы: мобильная нижняя навигация (Обзор / Разделы / Настройки) вместо боковой панели на телефоне
This commit is contained in:
135
client/src/components/finance/FinanceBottomNav.tsx
Normal file
135
client/src/components/finance/FinanceBottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user