ui(mobile): адаптация под смартфоны — drawer-меню, grid, модалка
Some checks failed
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Has been cancelled
Docker Images / API image (push) Successful in 33s
Docker Images / Web image (push) Successful in 25s
Docker Images / Deploy stage (push) Successful in 18s
Some checks failed
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Has been cancelled
Docker Images / API image (push) Successful in 33s
Docker Images / Web image (push) Successful in 25s
Docker Images / Deploy stage (push) Successful in 18s
Система теперь корректно работает на узких экранах (<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
ce20f78905
commit
481dcdf826
|
|
@ -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 { api } from '@/lib/api'
|
||||
import { logout } from '@/lib/auth'
|
||||
|
|
@ -6,7 +7,7 @@ import { cn } from '@/lib/utils'
|
|||
import {
|
||||
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
||||
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
||||
Boxes, History, TruckIcon, ShoppingCart, Settings,
|
||||
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
|
||||
|
|
@ -64,54 +65,98 @@ export function AppLayout() {
|
|||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
// Закрывать drawer при смене маршрута.
|
||||
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 />
|
||||
<button
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className="md:hidden text-slate-500 hover:text-slate-800"
|
||||
aria-label="Закрыть меню"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-3">
|
||||
{nav.map((section) => (
|
||||
<div key={section.group} className="mb-4">
|
||||
<div className="px-5 text-xs uppercase tracking-wide text-slate-400 mb-1">{section.group}</div>
|
||||
{section.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={'end' in item ? item.end : undefined}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-2.5 px-5 py-2 md:py-1.5 text-sm transition-colors',
|
||||
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)]'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-800 p-3">
|
||||
{me && (
|
||||
<div className="px-2 pb-2 text-xs text-slate-500">
|
||||
<div className="truncate font-medium text-slate-700 dark:text-slate-200">{me.name}</div>
|
||||
<div className="truncate">{me.roles.join(', ')}</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded"
|
||||
>
|
||||
<LogOut className="w-4 h-4" /> Выход
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-slate-50 dark:bg-slate-950 overflow-hidden">
|
||||
<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">
|
||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
||||
<Logo />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-3">
|
||||
{nav.map((section) => (
|
||||
<div key={section.group} className="mb-4">
|
||||
<div className="px-5 text-xs uppercase tracking-wide text-slate-400 mb-1">{section.group}</div>
|
||||
{section.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={'end' in item ? item.end : undefined}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-2.5 px-5 py-1.5 text-sm transition-colors',
|
||||
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)]'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-800 p-3">
|
||||
{me && (
|
||||
<div className="px-2 pb-2 text-xs text-slate-500">
|
||||
<div className="truncate font-medium text-slate-700 dark:text-slate-200">{me.name}</div>
|
||||
<div className="truncate">{me.roles.join(', ')}</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded"
|
||||
>
|
||||
<LogOut className="w-4 h-4" /> Выход
|
||||
</button>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
<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 />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,14 +60,14 @@ export function DataTable<T>({
|
|||
}
|
||||
|
||||
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">
|
||||
<tr>
|
||||
{columns.map((c, i) => (
|
||||
<th
|
||||
key={i}
|
||||
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,
|
||||
)}
|
||||
style={c.width ? { width: c.width } : undefined}
|
||||
|
|
@ -103,7 +103,7 @@ export function DataTable<T>({
|
|||
<td
|
||||
key={i}
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ export function ListPageShell({ title, description, actions, children, footer }:
|
|||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<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 && (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ export function Modal({ open, onClose, title, children, footer, width = 'max-w-l
|
|||
if (!open) return null
|
||||
|
||||
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
|
||||
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()}
|
||||
>
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
<div className="px-5 py-4 flex-1">{children}</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,25 +11,25 @@ interface PageHeaderProps {
|
|||
export function PageHeader({ title, description, actions, variant = 'plain' }: PageHeaderProps) {
|
||||
if (variant === 'bar') {
|
||||
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="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</h1>
|
||||
<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 flex-1 basis-full sm:basis-auto">
|
||||
<h1 className="text-base sm:text-lg font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-xs text-slate-500 mt-0.5 truncate">{description}</p>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-5">
|
||||
<div className="min-w-0">
|
||||
<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>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ interface SearchBarProps {
|
|||
|
||||
export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: SearchBarProps) {
|
||||
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" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function DashboardPage() {
|
|||
const hasAnySales = stats.data && stats.data.series.some((b) => b.revenue > 0)
|
||||
|
||||
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
|
||||
title="Dashboard"
|
||||
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function OrganizationSettingsPage() {
|
|||
|
||||
return (
|
||||
<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="Страна, валюта, ставка НДС по умолчанию." />
|
||||
|
||||
<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 })} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Страна">
|
||||
<Select value={form.countryCode} onChange={(e) => onCountryChange(e.target.value)}>
|
||||
{countries.data?.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
|
||||
|
|
@ -85,7 +85,7 @@ export function OrganizationSettingsPage() {
|
|||
Если выключено — в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Ставка НДС">
|
||||
<TextInput value={`${form.vatRate.toFixed(2)}%`} disabled />
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export function ProductEditPage() {
|
|||
|
||||
{/* Scrollable body */}
|
||||
<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 && (
|
||||
<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 }) {
|
||||
return (
|
||||
<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>
|
||||
{action}
|
||||
</header>
|
||||
<div className="p-5">{children}</div>
|
||||
<div className="p-4 sm:p-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { DataTable } from '@/components/DataTable'
|
|||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
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 { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||
|
|
@ -102,6 +102,7 @@ export function ProductsPage() {
|
|||
const showService = org.data?.showServiceOnProduct ?? false
|
||||
const showMarked = org.data?.showMarkedOnProduct ?? false
|
||||
const activeCount = activeFilterCount(filters)
|
||||
const [groupsOpen, setGroupsOpen] = useState(false)
|
||||
|
||||
type Col = {
|
||||
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)}%` : '—' })
|
||||
}
|
||||
|
||||
const groupsTree = (
|
||||
<ProductGroupTree
|
||||
selectedId={filters.groupId}
|
||||
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1); setGroupsOpen(false) }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<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
|
||||
selectedId={filters.groupId}
|
||||
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* 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>
|
||||
<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">
|
||||
<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>
|
||||
<p className="text-xs text-slate-500">
|
||||
{data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}
|
||||
</p>
|
||||
</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="Поиск: название, артикул, штрихкод…" />
|
||||
<Button
|
||||
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
|
||||
|
|
@ -167,7 +192,7 @@ export function ProductsPage() {
|
|||
|
||||
{/* Filter panel */}
|
||||
{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) }} />
|
||||
{showService && (
|
||||
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
|
||||
|
|
@ -217,7 +242,7 @@ export function ProductsPage() {
|
|||
</div>
|
||||
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ export function RetailSaleEditPage() {
|
|||
</div>
|
||||
|
||||
<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>}
|
||||
|
||||
<Section title="Реквизиты чека">
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ export function SupplyEditPage() {
|
|||
|
||||
{/* Scrollable body */}
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue