From a96b0fcdced11ea8b2eecd1772017e47e55dd0d6 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 12:56:13 +0300 Subject: [PATCH] Improve responsive commerce management layouts and create actions --- frontend/src/app/globals.css | 168 ++++++++++++++++++ frontend/src/app/page.tsx | 36 +++- frontend/src/app/products/manage/page.tsx | 60 ++++--- .../commerce/ProductConnectionsContent.tsx | 48 ++--- .../components/ui/AutoRevealStickyHeader.tsx | 61 +------ .../components/ui/HorizontalChipScroller.tsx | 4 +- frontend/src/hooks/useAutoRevealOnScroll.ts | 65 +++++++ 7 files changed, 329 insertions(+), 113 deletions(-) create mode 100644 frontend/src/hooks/useAutoRevealOnScroll.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index dc4a9429..ccf73a5e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -29,6 +29,174 @@ --shadow-elevated: 0 18px 40px -24px rgba(15, 23, 42, 0.18), 0 8px 16px -12px rgba(15, 23, 42, 0.1); } +/* Product management switches between cards and tables by the available + module width, not by the browser width. This keeps the regular desktop + three-column shell usable without forcing wide mode. */ +.commerce-management-section { + container-type: inline-size; +} + +.commerce-management-mobile-list { + display: block; +} + +.commerce-management-table { + display: none; +} + +@container (min-width: 44rem) { + .commerce-management-mobile-list { + display: none; + } + + .commerce-management-table { + display: block; + } +} + +.commerce-connections { + container-type: inline-size; +} + +.commerce-connection-card { + padding: 1rem; +} + +.commerce-connection-metrics, +.commerce-connection-meta, +.commerce-planned-services { + display: grid; + gap: 0.75rem; +} + +.commerce-connection-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.commerce-connection-meta, +.commerce-planned-services { + grid-template-columns: minmax(0, 1fr); +} + +.commerce-connection-schedule, +.commerce-connection-form-footer { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1rem; +} + +.commerce-connection-schedule-controls { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; +} + +.commerce-connection-actions, +.commerce-connection-form-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.5rem; +} + +.commerce-connection-actions > button, +.commerce-connection-form-actions > button { + width: 100%; + justify-content: center; +} + +.commerce-connection-auth-grid { + display: grid; + gap: 1.25rem; +} + +.commerce-planned-service-content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +@container (min-width: 28rem) { + .commerce-connection-card { + padding: 1.25rem; + } + + .commerce-connection-actions, + .commerce-connection-form-actions { + display: flex; + flex-wrap: wrap; + } + + .commerce-connection-actions > button, + .commerce-connection-form-actions > button { + width: auto; + } + + .commerce-planned-services { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .commerce-connection-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container (min-width: 44rem) { + .commerce-connection-card { + padding: 1.5rem; + } + + .commerce-connection-metrics-six { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .commerce-connection-metrics-four { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .commerce-connection-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .commerce-connection-schedule, + .commerce-connection-form-footer { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .commerce-connection-schedule-controls { + flex-flow: row wrap; + align-items: center; + } + + .commerce-connection-auth-grid { + grid-template-columns: minmax(15rem, 0.65fr) minmax(22.5rem, 1.35fr); + align-items: end; + } + + .commerce-planned-services { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .commerce-planned-service-content { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +@container (min-width: 64rem) { + .commerce-connection-metrics-six { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + + .commerce-connection-metrics-four { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 171538f8..cc4d5af2 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -19,6 +19,7 @@ import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks"; import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu"; import { useNotifications } from "@/contexts/NotificationsContext"; +import { useAutoRevealOnScroll } from "@/hooks/useAutoRevealOnScroll"; import { apiClient } from "@/lib/api"; import { getDocumentFileExtension } from "@/lib/document-preview"; import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications"; @@ -101,6 +102,7 @@ function HomePageContent() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const { visible: createActionVisibleOnScroll } = useAutoRevealOnScroll(); const [posts, setPosts] = useState([]); const [regulations, setRegulations] = useState([]); const [selectedRegulation, setSelectedRegulation] = useState(null); @@ -214,6 +216,12 @@ function HomePageContent() { ((user?.departments?.length || 0) > 0) ); const canCreatePost = canCreateCompanyPost || canCreateDepartmentPost; + const hasMultipleCreateActions = canCreatePost && canManageRegulations; + const createActionLabel = hasMultipleCreateActions + ? (createMenuOpen ? "Закрыть меню создания" : "Открыть меню создания") + : canCreatePost + ? "Создать новость" + : "Создать регламент"; const userDepartments = useMemo(() => user?.departments || [], [user?.departments]); const canManageAnyPost = Boolean( auth?.is_staff || @@ -1151,9 +1159,13 @@ function HomePageContent() { {user && (canCreatePost || canManageRegulations) && !createPostOpen && !createRegulationOpen ? (
- {createMenuOpen ? ( + {createMenuOpen && hasMultipleCreateActions ? (
{canCreatePost ? (
) : null} diff --git a/frontend/src/app/products/manage/page.tsx b/frontend/src/app/products/manage/page.tsx index de382f7e..b9bef756 100644 --- a/frontend/src/app/products/manage/page.tsx +++ b/frontend/src/app/products/manage/page.tsx @@ -3,6 +3,7 @@ import { AppShell } from "@/components/AppShell"; import { ProductConnectionsContent } from "@/components/commerce/ProductConnectionsContent"; import { Modal } from "@/components/ui"; +import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; import type { @@ -401,12 +402,12 @@ export default function CommerceManagementPage() { } return ( - -
+ +
К каталогу -
-
+
+

Управление товарами

@@ -421,19 +422,12 @@ export default function CommerceManagementPage() {
- - {tab !== "connections" ? ( ) : null} + + + {tabs.map((item) => { + const Icon = item.icon; + return ; + })} +
{error ?
{error}
: null} @@ -569,21 +570,36 @@ function EditorFields({ kind, editorId, form, setForm, products, sources, sales type TableActions = { onCreate: () => void; onEdit: (item: TItem) => void; onDelete: (id: TId) => void }; function Section({ title, subtitle, onCreate, children }: { title: string; subtitle: string; onCreate: () => void; children: ReactNode }) { - return

{title}

{subtitle}

{children}
; + return

{title}

{subtitle}

{children}
; } function Actions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return
; } function EmptyRow({ columns }: { columns: number }) { return Записей пока нет.; } -function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions) { return
{items.length ? items.map((item) => ) : }
{item.sku}{item.name}{productKindLabels[item.kind]}{item.bindings.length} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } -function SourcesTable({ items, onCreate, onEdit, onDelete }: { items: SourceSystem[] } & TableActions) { return
{items.length ? items.map((item) => ) : }
{item.code}{item.name}{item.is_active ? "Активен" : "Отключён"} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } -function BindingsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: ProductBinding[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => ) : }
{productMap.get(item.product)?.sku ?? item.product}{sourceMap.get(item.source)?.name ?? item.source_code}{item.external_id}{item.external_sku || "—"} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } -function SalesTable({ items, sourceMap, selectedSaleId, onSelect, onCreate, onEdit, onDelete }: { items: CommerceSale[]; sourceMap: Map; selectedSaleId: string | null; onSelect: (id: string) => void } & TableActions) { return
{items.length ? items.map((item) => ) : }
{sourceMap.get(item.source)?.name ?? item.source_code}{item.channel || "—"}{item.status || item.event_type}{displayDate(item.occurred_at)}{item.lines.length} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } -function SaleLinesTable({ sale, productMap, onCreate, onEdit, onDelete }: { sale: CommerceSale; productMap: Map } & TableActions) { return
{sale.lines.length ? sale.lines.map((item) => ) : }
{item.product ? productMap.get(item.product)?.name ?? item.name : item.name || "Не сопоставлен"}{item.external_sku || "—"}{displayNumber(item.quantity, 4)}{displayNumber(item.net_amount)}{displayNumber(item.cost_amount)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } -function CostsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceCost[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => ) : }
{item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"}{sourceMap.get(item.source)?.name ?? item.source_code}{costTypeLabels[item.cost_type] ?? item.cost_type}{item.channel || "—"}{displayNumber(item.amount)} {item.currency}{displayDate(item.occurred_at)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } -function StockTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceStockSnapshot[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => ) : }
{productMap.get(item.product)?.name ?? item.product}{sourceMap.get(item.source)?.name ?? item.source_code}{item.warehouse_name || item.warehouse_external_id}{displayNumber(item.quantity, 4)}{displayNumber(item.reserved, 4)}{displayNumber(item.available, 4)}{displayDate(item.captured_at)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function MobileList({ children }: { children: ReactNode }) { return
{children}
; } +function MobileEmpty() { return

Записей пока нет.

; } +function MobileCard({ title, eyebrow, badges, details, actions, className = "", onClick }: { title: ReactNode; eyebrow?: ReactNode; badges?: ReactNode; details?: Array<[string, ReactNode]>; actions: ReactNode; className?: string; onClick?: () => void }) { + const heading = <>{eyebrow ?
{eyebrow}
: null}
{title}
; + return
+
+ {onClick ? :
{heading}
} +
{actions}
+
+ {badges ?
{badges}
: null} + {details?.length ?
{details.map(([label, value]) =>
{label}
{value || "—"}
)}
: null} +
; +} +function Badge({ children }: { children: ReactNode }) { return {children}; } -function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return
{headers.map((header, index) => )}{children}
{header}
; } +function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions) { return
{items.length ? items.map((item) => {productKindLabels[item.kind]}Привязки · {item.bindings.length}} actions={ onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : }{items.length ? items.map((item) => ) : }
{item.sku}{item.name}{productKindLabels[item.kind]}{item.bindings.length} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function SourcesTable({ items, onCreate, onEdit, onDelete }: { items: SourceSystem[] } & TableActions) { return
{items.length ? items.map((item) => {item.is_active ? "Активен" : "Отключён"}} actions={ onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : }{items.length ? items.map((item) => ) : }
{item.code}{item.name}{item.is_active ? "Активен" : "Отключён"} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function BindingsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: ProductBinding[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : }{items.length ? items.map((item) => ) : }
{productMap.get(item.product)?.sku ?? item.product}{sourceMap.get(item.source)?.name ?? item.source_code}{item.external_id}{item.external_sku || "—"} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function SalesTable({ items, sourceMap, selectedSaleId, onSelect, onCreate, onEdit, onDelete }: { items: CommerceSale[]; sourceMap: Map; selectedSaleId: string | null; onSelect: (id: string) => void } & TableActions) { return
{items.length ? items.map((item) => onSelect(item.id)} badges={<>{item.status || item.event_type}Позиции · {item.lines.length}} details={[["Канал", item.channel || "—"], ["Дата", displayDate(item.occurred_at)]]} actions={ onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : }{items.length ? items.map((item) => ) : }
{sourceMap.get(item.source)?.name ?? item.source_code}{item.channel || "—"}{item.status || item.event_type}{displayDate(item.occurred_at)}{item.lines.length} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function SaleLinesTable({ sale, productMap, onCreate, onEdit, onDelete }: { sale: CommerceSale; productMap: Map } & TableActions) { return
{sale.lines.length ? sale.lines.map((item) => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : }{sale.lines.length ? sale.lines.map((item) => ) : }
{item.product ? productMap.get(item.product)?.name ?? item.name : item.name || "Не сопоставлен"}{item.external_sku || "—"}{displayNumber(item.quantity, 4)}{displayNumber(item.net_amount)}{displayNumber(item.cost_amount)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function CostsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceCost[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => {costTypeLabels[item.cost_type] ?? item.cost_type}} details={[["Сумма", `${displayNumber(item.amount)} ${item.currency}`], ["Канал", item.channel || "—"], ["Дата", displayDate(item.occurred_at)]]} actions={ onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : }{items.length ? items.map((item) => ) : }
{item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"}{sourceMap.get(item.source)?.name ?? item.source_code}{costTypeLabels[item.cost_type] ?? item.cost_type}{item.channel || "—"}{displayNumber(item.amount)} {item.currency}{displayDate(item.occurred_at)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function StockTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceStockSnapshot[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => {item.warehouse_name || item.warehouse_external_id}} details={[["Количество", displayNumber(item.quantity, 4)], ["Резерв", displayNumber(item.reserved, 4)], ["Доступно", displayNumber(item.available, 4)], ["Дата", displayDate(item.captured_at)]]} actions={ onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : }{items.length ? items.map((item) => ) : }
{productMap.get(item.product)?.name ?? item.product}{sourceMap.get(item.source)?.name ?? item.source_code}{item.warehouse_name || item.warehouse_external_id}{displayNumber(item.quantity, 4)}{displayNumber(item.reserved, 4)}{displayNumber(item.available, 4)}{displayDate(item.captured_at)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } + +function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return
{headers.map((header, index) => )}{children}
{header}
; } function Td({ children }: { children: ReactNode }) { return {children}; } function Field({ label, value, onChange, required = false, type = "text", step, placeholder, multiline = false, className = "" }: { label: string; value: string; onChange: (value: string) => void; required?: boolean; type?: string; step?: string; placeholder?: string; multiline?: boolean; className?: string }) { const controlClass = "mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none focus:border-[var(--border-strong)]"; return