ui(mobile): адаптация под смартфоны — drawer-меню, grid, модалка
Система теперь корректно работает на узких экранах (<768px): - AppLayout: на мобиле фиксированный sidebar заменён hamburger-меню (Menu icon) + off-canvas drawer с overlay. На md+ — прежний sidebar. - ProductsPage: дерево групп тоже превращается в drawer на мобиле, кнопка «Группы» рядом с заголовком; фильтры flex-wrap. - Modal: на мобиле (<sm) разворачивается на весь экран (items-stretch, min-h-full, убраны скругления и верхний отступ). - DataTable: мин-ширина 640px + whitespace-nowrap в заголовках, уменьшен горизонтальный padding на мобиле. Родительский overflow-auto даёт плавный горизонтальный скролл. - PageHeader/ListPageShell: flex-wrap, меньший padding на мобиле. - SearchBar: flex-1 на узких (занимает доступное место), фикс 256px на sm+. - ProductEditPage Grid helper: 3/4 колонки теперь grid-cols-1 sm: 2 md: 3/4 — поля не слипаются на телефоне. - ProductEditPage/Supply/RetailSale/Dashboard/OrganizationSettings: отступы p-3 sm:p-6, grid grid-cols-2 на страну/валюту → 1 col на мобиле. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
38f117b2a4
commit
68ccc9fa1d
|
|
@ -1,4 +1,5 @@
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { logout } from '@/lib/auth'
|
import { logout } from '@/lib/auth'
|
||||||
|
|
@ -6,7 +7,7 @@ import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
||||||
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
||||||
Boxes, History, TruckIcon, ShoppingCart, Settings,
|
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
|
|
||||||
|
|
@ -64,11 +65,22 @@ export function AppLayout() {
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
<div className="h-screen flex bg-slate-50 dark:bg-slate-950 overflow-hidden">
|
const location = useLocation()
|
||||||
<aside className="w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col h-full">
|
// Закрывать drawer при смене маршрута.
|
||||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
useEffect(() => { setDrawerOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
|
const sidebar = (
|
||||||
|
<>
|
||||||
|
<div className="h-14 flex items-center justify-between px-5 border-b border-slate-200 dark:border-slate-800">
|
||||||
<Logo />
|
<Logo />
|
||||||
|
<button
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
className="md:hidden text-slate-500 hover:text-slate-800"
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto py-3">
|
<nav className="flex-1 overflow-y-auto py-3">
|
||||||
|
|
@ -81,7 +93,7 @@ export function AppLayout() {
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={'end' in item ? item.end : undefined}
|
end={'end' in item ? item.end : undefined}
|
||||||
className={({ isActive }) => cn(
|
className={({ isActive }) => cn(
|
||||||
'flex items-center gap-2.5 px-5 py-1.5 text-sm transition-colors',
|
'flex items-center gap-2.5 px-5 py-2 md:py-1.5 text-sm transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[var(--color-brand-light)] dark:bg-[var(--color-brand-dark)]/20 text-[var(--color-brand-dark)] dark:text-[var(--color-brand-light)] font-medium border-r-2 border-[var(--color-brand)]'
|
? 'bg-[var(--color-brand-light)] dark:bg-[var(--color-brand-dark)]/20 text-[var(--color-brand-dark)] dark:text-[var(--color-brand-light)] font-medium border-r-2 border-[var(--color-brand)]'
|
||||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||||
|
|
@ -109,9 +121,42 @@ export function AppLayout() {
|
||||||
<LogOut className="w-4 h-4" /> Выход
|
<LogOut className="w-4 h-4" /> Выход
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-slate-950 overflow-hidden">
|
||||||
|
{/* Mobile header with hamburger — только на узких экранах. */}
|
||||||
|
<header className="md:hidden h-12 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
className="text-slate-600 dark:text-slate-300"
|
||||||
|
aria-label="Открыть меню"
|
||||||
|
>
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<Logo />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Desktop sidebar — закреплён слева на md+. */}
|
||||||
|
<aside className="hidden md:flex w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex-col h-full">
|
||||||
|
{sidebar}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
{/* Mobile drawer — overlay + сдвигающаяся панель. */}
|
||||||
|
{drawerOpen && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-40" role="dialog" aria-modal="true">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm"
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
/>
|
||||||
|
<aside className="absolute left-0 top-0 bottom-0 w-72 max-w-[85vw] bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col shadow-xl">
|
||||||
|
{sidebar}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,14 @@ export function DataTable<T>({
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = (
|
const table = (
|
||||||
<table className="w-full text-sm border-separate border-spacing-0">
|
<table className="w-full min-w-[640px] text-sm border-separate border-spacing-0">
|
||||||
<thead className="sticky top-0 z-10 bg-slate-50 dark:bg-slate-800/90 backdrop-blur text-left">
|
<thead className="sticky top-0 z-10 bg-slate-50 dark:bg-slate-800/90 backdrop-blur text-left">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((c, i) => (
|
{columns.map((c, i) => (
|
||||||
<th
|
<th
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200 dark:border-slate-700',
|
'px-3 sm:px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200 dark:border-slate-700 whitespace-nowrap',
|
||||||
c.className,
|
c.className,
|
||||||
)}
|
)}
|
||||||
style={c.width ? { width: c.width } : undefined}
|
style={c.width ? { width: c.width } : undefined}
|
||||||
|
|
@ -103,7 +103,7 @@ export function DataTable<T>({
|
||||||
<td
|
<td
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2.5 text-slate-700 dark:text-slate-200 border-b border-slate-100 dark:border-slate-800',
|
'px-3 sm:px-4 py-2.5 text-slate-700 dark:text-slate-200 border-b border-slate-100 dark:border-slate-800',
|
||||||
c.className,
|
c.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ export function ListPageShell({ title, description, actions, children, footer }:
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader variant="bar" title={title} description={description} actions={actions} />
|
<PageHeader variant="bar" title={title} description={description} actions={actions} />
|
||||||
<div className="flex-1 min-h-0 p-4">{children}</div>
|
<div className="flex-1 min-h-0 p-3 sm:p-4">{children}</div>
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-4 py-2">
|
<div className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-3 sm:px-4 py-2">
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ export function Modal({ open, onClose, title, children, footer, width = 'max-w-l
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 overflow-y-auto bg-slate-900/50 backdrop-blur-sm" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-stretch sm:items-start justify-center sm:p-4 overflow-y-auto bg-slate-900/50 backdrop-blur-sm" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
className={`w-full ${width} mt-16 bg-white dark:bg-slate-900 rounded-xl shadow-xl`}
|
className={`w-full ${width} min-h-full sm:min-h-0 sm:mt-16 bg-white dark:bg-slate-900 sm:rounded-xl shadow-xl flex flex-col`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-200 dark:border-slate-800">
|
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
|
@ -32,9 +32,9 @@ export function Modal({ open, onClose, title, children, footer, width = 'max-w-l
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">{children}</div>
|
<div className="px-5 py-4 flex-1">{children}</div>
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="px-5 py-3 border-t border-slate-200 dark:border-slate-800 flex items-center justify-end gap-2">
|
<div className="px-5 py-3 border-t border-slate-200 dark:border-slate-800 flex flex-wrap items-center justify-end gap-2">
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,25 @@ interface PageHeaderProps {
|
||||||
export function PageHeader({ title, description, actions, variant = 'plain' }: PageHeaderProps) {
|
export function PageHeader({ title, description, actions, variant = 'plain' }: PageHeaderProps) {
|
||||||
if (variant === 'bar') {
|
if (variant === 'bar') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-3 sm:py-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1 basis-full sm:basis-auto">
|
||||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</h1>
|
<h1 className="text-base sm:text-lg font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</h1>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-xs text-slate-500 mt-0.5 truncate">{description}</p>
|
<p className="text-xs text-slate-500 mt-0.5 truncate">{description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
|
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 mb-5">
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-5">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
|
<h1 className="text-lg sm:text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
|
||||||
{description && <p className="text-sm text-slate-500 mt-0.5">{description}</p>}
|
{description && <p className="text-sm text-slate-500 mt-0.5">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ interface SearchBarProps {
|
||||||
|
|
||||||
export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: SearchBarProps) {
|
export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: SearchBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative flex-1 min-w-[180px] sm:flex-none">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="pl-8 pr-3 py-1.5 w-64 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]"
|
className="pl-8 pr-3 py-1.5 w-full sm:w-64 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export function DashboardPage() {
|
||||||
const hasAnySales = stats.data && stats.data.series.some((b) => b.revenue > 0)
|
const hasAnySales = stats.data && stats.data.series.some((b) => b.revenue > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 overflow-auto">
|
<div className="p-4 sm:p-6 space-y-5 sm:space-y-6 overflow-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
|
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export function OrganizationSettingsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
<div className="p-6 max-w-2xl">
|
<div className="p-4 sm:p-6 max-w-2xl">
|
||||||
<PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." />
|
<PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." />
|
||||||
|
|
||||||
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||||
|
|
@ -62,7 +62,7 @@ export function OrganizationSettingsPage() {
|
||||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Field label="Страна">
|
<Field label="Страна">
|
||||||
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
|
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
|
||||||
{countries.data?.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
{countries.data?.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||||
|
|
@ -85,7 +85,7 @@ export function OrganizationSettingsPage() {
|
||||||
Если выключено — в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию.
|
Если выключено — в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Field label="Ставка НДС">
|
<Field label="Ставка НДС">
|
||||||
<TextInput value={`${form.vatRate.toFixed(2)}%`} disabled />
|
<TextInput value={`${form.vatRate.toFixed(2)}%`} disabled />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ export function ProductEditPage() {
|
||||||
|
|
||||||
{/* Scrollable body */}
|
{/* Scrollable body */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -433,17 +433,17 @@ export function ProductEditPage() {
|
||||||
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
|
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||||
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
|
<header className="flex flex-wrap items-center justify-between gap-2 px-4 sm:px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
|
||||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
|
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
|
||||||
{action}
|
{action}
|
||||||
</header>
|
</header>
|
||||||
<div className="p-5">{children}</div>
|
<div className="p-4 sm:p-5">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Grid({ cols, children }: { cols: 2 | 3 | 4; children: ReactNode }) {
|
function Grid({ cols, children }: { cols: 2 | 3 | 4; children: ReactNode }) {
|
||||||
const cls = cols === 2 ? 'grid-cols-1 md:grid-cols-2' : cols === 3 ? 'grid-cols-1 md:grid-cols-3' : 'grid-cols-2 md:grid-cols-4'
|
const cls = cols === 2 ? 'grid-cols-1 sm:grid-cols-2' : cols === 3 ? 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2 md:grid-cols-4'
|
||||||
return <div className={`grid ${cls} gap-x-4 gap-y-3`}>{children}</div>
|
return <div className={`grid ${cls} gap-x-4 gap-y-3`}>{children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Plus, Filter, X } from 'lucide-react'
|
import { Plus, Filter, X, FolderTree } from 'lucide-react'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||||
|
|
@ -102,6 +102,7 @@ export function ProductsPage() {
|
||||||
const showService = org.data?.showServiceOnProduct ?? false
|
const showService = org.data?.showServiceOnProduct ?? false
|
||||||
const showMarked = org.data?.showMarkedOnProduct ?? false
|
const showMarked = org.data?.showMarkedOnProduct ?? false
|
||||||
const activeCount = activeFilterCount(filters)
|
const activeCount = activeFilterCount(filters)
|
||||||
|
const [groupsOpen, setGroupsOpen] = useState(false)
|
||||||
|
|
||||||
type Col = {
|
type Col = {
|
||||||
header: string
|
header: string
|
||||||
|
|
@ -131,27 +132,51 @@ export function ProductsPage() {
|
||||||
baseColumns.push({ header: 'НДС', width: '90px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat.toFixed(2)}%` : '—' })
|
baseColumns.push({ header: 'НДС', width: '90px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat.toFixed(2)}%` : '—' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const groupsTree = (
|
||||||
<div className="flex h-full min-h-0">
|
|
||||||
{/* Left: groups tree */}
|
|
||||||
<aside className="w-64 flex-shrink-0 border-r border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-900/40 flex flex-col min-h-0">
|
|
||||||
<ProductGroupTree
|
<ProductGroupTree
|
||||||
selectedId={filters.groupId}
|
selectedId={filters.groupId}
|
||||||
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
|
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1); setGroupsOpen(false) }}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
{/* Desktop groups tree — закреплён слева на md+. */}
|
||||||
|
<aside className="hidden md:flex w-64 flex-shrink-0 border-r border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-900/40 flex-col min-h-0">
|
||||||
|
{groupsTree}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile groups drawer. */}
|
||||||
|
{groupsOpen && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-40" role="dialog" aria-modal="true">
|
||||||
|
<div className="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" onClick={() => setGroupsOpen(false)} />
|
||||||
|
<aside className="absolute left-0 top-0 bottom-0 w-72 max-w-[85vw] bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col shadow-xl">
|
||||||
|
<div className="h-12 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<span className="font-medium text-sm">Группы товаров</span>
|
||||||
|
<button onClick={() => setGroupsOpen(false)} className="text-slate-500"><X className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">{groupsTree}</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Right: products */}
|
{/* Right: products */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
|
<div className="px-4 sm:px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
|
||||||
<div>
|
<button
|
||||||
|
onClick={() => setGroupsOpen(true)}
|
||||||
|
className="md:hidden inline-flex items-center gap-1.5 text-sm text-slate-600 dark:text-slate-300 px-2 py-1 rounded border border-slate-200 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<FolderTree className="w-4 h-4" /> Группы
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0">
|
||||||
<h1 className="text-base font-semibold">Товары</h1>
|
<h1 className="text-base font-semibold">Товары</h1>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
{data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}
|
{data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
|
<SearchBar value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
|
||||||
<Button
|
<Button
|
||||||
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
|
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
|
||||||
|
|
@ -167,7 +192,7 @@ export function ProductsPage() {
|
||||||
|
|
||||||
{/* Filter panel */}
|
{/* Filter panel */}
|
||||||
{filtersOpen && (
|
{filtersOpen && (
|
||||||
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-4 items-center">
|
<div className="px-4 sm:px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-3 sm:gap-4 items-center">
|
||||||
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
|
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
|
||||||
{showService && (
|
{showService && (
|
||||||
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
|
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
|
||||||
|
|
@ -217,7 +242,7 @@ export function ProductsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && data.total > 0 && (
|
{data && data.total > 0 && (
|
||||||
<div className="px-6 py-3 border-t border-slate-200 dark:border-slate-800">
|
<div className="px-4 sm:px-6 py-3 border-t border-slate-200 dark:border-slate-800">
|
||||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ export function RetailSaleEditPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-6xl mx-auto p-6 space-y-5">
|
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
|
||||||
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
|
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
|
||||||
|
|
||||||
<Section title="Реквизиты чека">
|
<Section title="Реквизиты чека">
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ export function SupplyEditPage() {
|
||||||
|
|
||||||
{/* Scrollable body */}
|
{/* Scrollable body */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-6xl mx-auto p-6 space-y-5">
|
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue