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:
nns 2026-04-24 19:17:56 +05:00
parent 38f117b2a4
commit 68ccc9fa1d
12 changed files with 157 additions and 87 deletions

View file

@ -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>

View file

@ -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,
)}
>

View file

@ -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>
)}

View file

@ -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>
)}

View file

@ -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>
)
}

View file

@ -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>
)

View file

@ -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}` : 'Сводка по продажам и каталогу'}

View file

@ -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>

View file

@ -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>
}

View file

@ -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>
)}

View file

@ -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="Реквизиты чека">

View file

@ -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>
)}