Improve responsive commerce management layouts and create actions

This commit is contained in:
lizerk 2026-08-19 12:56:13 +03:00
parent 5a877880ae
commit a96b0fcdce
7 changed files with 329 additions and 113 deletions

View File

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

View File

@ -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<Post[]>([]);
const [regulations, setRegulations] = useState<Document[]>([]);
const [selectedRegulation, setSelectedRegulation] = useState<Document | null>(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 ? (
<div
ref={createMenuRef}
className="pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 flex flex-col items-end gap-2 lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]"
className={`${
createMenuOpen || createActionVisibleOnScroll
? "translate-y-0 opacity-100"
: "translate-y-[calc(100%+7rem)] opacity-0"
} pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 flex flex-col items-end gap-2 transition-[transform,opacity] duration-200 ease-out will-change-transform lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]`}
>
{createMenuOpen ? (
{createMenuOpen && hasMultipleCreateActions ? (
<div className="flex flex-col items-end gap-2" role="menu" aria-label="Создать в ленте">
{canCreatePost ? (
<button
@ -1181,14 +1193,22 @@ function HomePageContent() {
) : null}
<button
type="button"
onClick={() => setCreateMenuOpen((current) => !current)}
onClick={() => {
if (hasMultipleCreateActions) {
setCreateMenuOpen((current) => !current);
} else if (canCreatePost) {
openCreatePostModal();
} else {
openCreateRegulationModal();
}
}}
className="app-action-primary pointer-events-auto inline-flex h-12 w-12 items-center justify-center rounded-full p-0 leading-none shadow-[var(--shadow-card)] transition active:scale-[0.98]"
title={createMenuOpen ? "Закрыть меню создания" : "Создать"}
aria-label={createMenuOpen ? "Закрыть меню создания" : "Открыть меню создания"}
aria-expanded={createMenuOpen}
aria-haspopup="menu"
title={createActionLabel}
aria-label={createActionLabel}
aria-expanded={hasMultipleCreateActions ? createMenuOpen : undefined}
aria-haspopup={hasMultipleCreateActions ? "menu" : "dialog"}
>
<Plus size={22} className={`transition-transform duration-200 ${createMenuOpen ? "rotate-45" : ""}`} />
<Plus size={22} className={`transition-transform duration-200 ${createMenuOpen && hasMultipleCreateActions ? "rotate-45" : ""}`} />
</button>
</div>
) : null}

View File

@ -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 (
<AppShell desktopWideMode>
<main className="mx-auto w-full min-w-0 max-w-[1600px] space-y-4 p-4 sm:p-6 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<AppShell>
<main className="mx-auto w-full min-w-0 max-w-[1600px] space-y-3 sm:space-y-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<Link href="/products" className="app-text-muted inline-flex items-center gap-2 text-sm hover:text-[var(--foreground)]"><ArrowLeft className="h-4 w-4" /> К каталогу</Link>
<section className="app-surface min-w-0 overflow-hidden rounded-2xl p-4">
<header className="flex flex-wrap items-start justify-between gap-4">
<section className="app-surface min-w-0 overflow-hidden rounded-2xl p-3 sm:p-4">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<h1 className="text-xl font-semibold sm:text-2xl">Управление товарами</h1>
@ -421,19 +422,12 @@ export default function CommerceManagementPage() {
<button
type="button"
onClick={() => tab === "connections" ? setConnectionsRefreshKey((current) => current + 1) : void loadData()}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
className="app-action-secondary inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> Обновить
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> <span className="hidden sm:inline">Обновить</span>
</button>
</header>
<nav className="mt-4 flex max-w-full gap-1 overflow-x-auto rounded-xl bg-[var(--surface-secondary)] p-1.5" aria-label="Разделы управления товарами">
{tabs.map((item) => {
const Icon = item.icon;
return <button key={item.id} type="button" onClick={() => selectTab(item.id)} aria-current={tab === item.id ? "page" : undefined} className={`inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition ${tab === item.id ? "app-selected" : "app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"}`}><Icon className="h-4 w-4" /> {item.label}</button>;
})}
</nav>
{tab !== "connections" ? (
<label className="relative mt-4 block min-w-0">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
@ -450,6 +444,13 @@ export default function CommerceManagementPage() {
/>
</label>
) : null}
<HorizontalChipScroller className="mt-4" ariaLabel="Разделы управления товарами">
{tabs.map((item) => {
const Icon = item.icon;
return <button key={item.id} type="button" onClick={() => selectTab(item.id)} aria-current={tab === item.id ? "page" : undefined} className={`inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${tab === item.id ? "app-selected" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"}`}><Icon className="h-3.5 w-3.5" /> {item.label}</button>;
})}
</HorizontalChipScroller>
</section>
{error ? <div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div> : null}
@ -569,21 +570,36 @@ function EditorFields({ kind, editorId, form, setForm, products, sources, sales
type TableActions<TId extends string | number, TItem> = { 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 <section className="app-surface min-w-0 overflow-hidden rounded-2xl"><header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border-subtle)] p-4"><div className="min-w-0"><h2 className="font-semibold">{title}</h2><p className="app-text-muted mt-1 text-xs">{subtitle}</p></div><button type="button" onClick={onCreate} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><Plus className="h-4 w-4" /> Добавить</button></header>{children}</section>;
return <section className="commerce-management-section app-surface min-w-0 overflow-hidden rounded-2xl"><header className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] p-3 sm:p-4"><div className="min-w-0"><h2 className="truncate font-semibold">{title}</h2><p className="app-text-muted mt-1 line-clamp-2 text-xs">{subtitle}</p></div><button type="button" onClick={onCreate} className="app-action-primary inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg px-3 text-sm" aria-label={`Добавить: ${title}`}><Plus className="h-4 w-4" /><span className="hidden min-[390px]:inline">Добавить</span></button></header>{children}</section>;
}
function Actions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return <div className="flex justify-end gap-1"><button type="button" onClick={onEdit} className="app-icon-button rounded-lg p-2" title="Изменить" aria-label="Изменить запись"><Pencil className="h-4 w-4" /></button><button type="button" onClick={onDelete} className="app-icon-button rounded-lg p-2 text-rose-600" title="Удалить" aria-label="Удалить запись"><Trash2 className="h-4 w-4" /></button></div>; }
function EmptyRow({ columns }: { columns: number }) { return <tr><td colSpan={columns} className="p-8 text-center text-sm text-[var(--muted-foreground)]">Записей пока нет.</td></tr>; }
function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions<string, CommerceProduct>) { return <Section title="Товары" subtitle="Единый каталог и внутренние артикулы" onCreate={onCreate}><Table headers={["SKU", "Название", "Тип", "Привязки", ""]}>{items.length ? items.map((item) => <tr key={item.id} className="transition hover:bg-[var(--surface-secondary)]"><Td><span className="font-medium">{item.sku}</span></Td><Td>{item.name}</Td><Td><span className="app-badge rounded-full px-2 py-0.5 text-[10px]">{productKindLabels[item.kind]}</span></Td><Td>{item.bindings.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
function SourcesTable({ items, onCreate, onEdit, onDelete }: { items: SourceSystem[] } & TableActions<number, SourceSystem>) { return <Section title="Источники" subtitle="Сервисы, API и импортируемые отчёты" onCreate={onCreate}><Table headers={["Код", "Название", "Статус", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.code}</Td><Td>{item.name}</Td><Td>{item.is_active ? "Активен" : "Отключён"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={4} />}</Table></Section>; }
function BindingsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: ProductBinding[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, ProductBinding>) { return <Section title="Внешние привязки" subtitle="Соответствия товаров и офферов разных систем" onCreate={onCreate}><Table headers={["Товар", "Источник", "Внешний ID", "Внешний SKU", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.sku ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.external_id}</Td><Td>{item.external_sku || "—"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
function SalesTable({ items, sourceMap, selectedSaleId, onSelect, onCreate, onEdit, onDelete }: { items: CommerceSale[]; sourceMap: Map<number, SourceSystem>; selectedSaleId: string | null; onSelect: (id: string) => void } & TableActions<string, CommerceSale>) { return <Section title="Продажи и возвраты" subtitle="Выберите продажу, чтобы управлять её позициями" onCreate={onCreate}><Table headers={["Номер", "Источник", "Канал", "Статус", "Дата", "Позиции", ""]}>{items.length ? items.map((item) => <tr key={item.id} className={selectedSaleId === item.id ? "bg-[var(--surface-secondary)]" : ""}><Td><button type="button" onClick={() => onSelect(item.id)} className="font-medium hover:underline">{item.number || item.external_id}</button></Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.channel || "—"}</Td><Td>{item.status || item.event_type}</Td><Td>{displayDate(item.occurred_at)}</Td><Td>{item.lines.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
function SaleLinesTable({ sale, productMap, onCreate, onEdit, onDelete }: { sale: CommerceSale; productMap: Map<string, CommerceProduct> } & TableActions<string, CommerceSaleLine>) { return <Section title={`Позиции · ${sale.number || sale.external_id}`} subtitle="Товарные строки выбранной продажи" onCreate={onCreate}><Table headers={["Товар", "Внешний SKU", "Количество", "Выручка", "Себестоимость", ""]}>{sale.lines.length ? sale.lines.map((item) => <tr key={item.id}><Td>{item.product ? productMap.get(item.product)?.name ?? item.name : item.name || "Не сопоставлен"}</Td><Td>{item.external_sku || "—"}</Td><Td>{displayNumber(item.quantity, 4)}</Td><Td>{displayNumber(item.net_amount)}</Td><Td>{displayNumber(item.cost_amount)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={6} />}</Table></Section>; }
function CostsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceCost[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceCost>) { return <Section title="Расходы" subtitle="Комиссии, логистика, реклама и прочие затраты" onCreate={onCreate}><Table headers={["Товар", "Источник", "Тип", "Канал", "Сумма", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{costTypeLabels[item.cost_type] ?? item.cost_type}</Td><Td>{item.channel || "—"}</Td><Td>{displayNumber(item.amount)} {item.currency}</Td><Td>{displayDate(item.occurred_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
function StockTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceStockSnapshot[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceStockSnapshot>) { return <Section title="Снимки остатков" subtitle="Наблюдения по складам и источникам" onCreate={onCreate}><Table headers={["Товар", "Источник", "Склад", "Количество", "Резерв", "Доступно", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.name ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.warehouse_name || item.warehouse_external_id}</Td><Td>{displayNumber(item.quantity, 4)}</Td><Td>{displayNumber(item.reserved, 4)}</Td><Td>{displayNumber(item.available, 4)}</Td><Td>{displayDate(item.captured_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={8} />}</Table></Section>; }
function MobileList({ children }: { children: ReactNode }) { return <div className="commerce-management-mobile-list space-y-2 p-3">{children}</div>; }
function MobileEmpty() { return <p className="app-text-muted p-6 text-center text-sm">Записей пока нет.</p>; }
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 ? <div className="app-text-muted truncate text-[11px]">{eyebrow}</div> : null}<div className="mt-0.5 break-words text-sm font-semibold">{title}</div></>;
return <article className={`rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-secondary)] p-3 ${className}`}>
<div className="flex min-w-0 items-start justify-between gap-2">
{onClick ? <button type="button" onClick={onClick} className="min-w-0 flex-1 cursor-pointer text-left">{heading}</button> : <div className="min-w-0 flex-1 text-left">{heading}</div>}
<div className="shrink-0">{actions}</div>
</div>
{badges ? <div className="mt-2 flex flex-wrap gap-1.5">{badges}</div> : null}
{details?.length ? <dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">{details.map(([label, value]) => <div key={label} className="min-w-0"><dt className="app-text-muted">{label}</dt><dd className="mt-0.5 break-words">{value || "—"}</dd></div>)}</dl> : null}
</article>;
}
function Badge({ children }: { children: ReactNode }) { return <span className="app-badge rounded-full px-2 py-1 text-[10px]">{children}</span>; }
function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return <div className="max-w-full overflow-x-auto overscroll-x-contain"><table className="w-full min-w-[760px] text-left text-sm"><thead className="sticky top-0 z-10 bg-[var(--surface-secondary)] text-xs uppercase tracking-wide text-[var(--muted-foreground)]"><tr>{headers.map((header, index) => <th key={`${header}-${index}`} className="px-4 py-3 font-medium">{header}</th>)}</tr></thead><tbody className="divide-y divide-[var(--border-subtle)]">{children}</tbody></table></div>; }
function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions<string, CommerceProduct>) { return <Section title="Товары" subtitle="Единый каталог и внутренние артикулы" onCreate={onCreate}><MobileList>{items.length ? items.map((item) => <MobileCard key={item.id} eyebrow={`SKU · ${item.sku}`} title={item.name} badges={<><Badge>{productKindLabels[item.kind]}</Badge><Badge>Привязки · {item.bindings.length}</Badge></>} actions={<Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : <MobileEmpty />}</MobileList><Table headers={["SKU", "Название", "Тип", "Привязки", ""]}>{items.length ? items.map((item) => <tr key={item.id} className="transition hover:bg-[var(--surface-secondary)]"><Td><span className="font-medium">{item.sku}</span></Td><Td>{item.name}</Td><Td><span className="app-badge rounded-full px-2 py-0.5 text-[10px]">{productKindLabels[item.kind]}</span></Td><Td>{item.bindings.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
function SourcesTable({ items, onCreate, onEdit, onDelete }: { items: SourceSystem[] } & TableActions<number, SourceSystem>) { return <Section title="Источники" subtitle="Сервисы, API и импортируемые отчёты" onCreate={onCreate}><MobileList>{items.length ? items.map((item) => <MobileCard key={item.id} eyebrow={`Код · ${item.code}`} title={item.name} badges={<Badge>{item.is_active ? "Активен" : "Отключён"}</Badge>} actions={<Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : <MobileEmpty />}</MobileList><Table headers={["Код", "Название", "Статус", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.code}</Td><Td>{item.name}</Td><Td>{item.is_active ? "Активен" : "Отключён"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={4} />}</Table></Section>; }
function BindingsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: ProductBinding[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, ProductBinding>) { return <Section title="Внешние привязки" subtitle="Соответствия товаров и офферов разных систем" onCreate={onCreate}><MobileList>{items.length ? items.map((item) => <MobileCard key={item.id} eyebrow={sourceMap.get(item.source)?.name ?? item.source_code} title={productMap.get(item.product)?.sku ?? item.product} details={[["Внешний ID", item.external_id], ["Внешний SKU", item.external_sku || "—"]]} actions={<Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : <MobileEmpty />}</MobileList><Table headers={["Товар", "Источник", "Внешний ID", "Внешний SKU", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.sku ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.external_id}</Td><Td>{item.external_sku || "—"}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={5} />}</Table></Section>; }
function SalesTable({ items, sourceMap, selectedSaleId, onSelect, onCreate, onEdit, onDelete }: { items: CommerceSale[]; sourceMap: Map<number, SourceSystem>; selectedSaleId: string | null; onSelect: (id: string) => void } & TableActions<string, CommerceSale>) { return <Section title="Продажи и возвраты" subtitle="Выберите продажу, чтобы управлять её позициями" onCreate={onCreate}><MobileList>{items.length ? items.map((item) => <MobileCard key={item.id} className={selectedSaleId === item.id ? "border-[var(--accent-primary)]" : ""} eyebrow={sourceMap.get(item.source)?.name ?? item.source_code} title={item.number || item.external_id} onClick={() => onSelect(item.id)} badges={<><Badge>{item.status || item.event_type}</Badge><Badge>Позиции · {item.lines.length}</Badge></>} details={[["Канал", item.channel || "—"], ["Дата", displayDate(item.occurred_at)]]} actions={<Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : <MobileEmpty />}</MobileList><Table headers={["Номер", "Источник", "Канал", "Статус", "Дата", "Позиции", ""]}>{items.length ? items.map((item) => <tr key={item.id} className={selectedSaleId === item.id ? "bg-[var(--surface-secondary)]" : ""}><Td><button type="button" onClick={() => onSelect(item.id)} className="font-medium hover:underline">{item.number || item.external_id}</button></Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.channel || "—"}</Td><Td>{item.status || item.event_type}</Td><Td>{displayDate(item.occurred_at)}</Td><Td>{item.lines.length}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
function SaleLinesTable({ sale, productMap, onCreate, onEdit, onDelete }: { sale: CommerceSale; productMap: Map<string, CommerceProduct> } & TableActions<string, CommerceSaleLine>) { return <Section title={`Позиции · ${sale.number || sale.external_id}`} subtitle="Товарные строки выбранной продажи" onCreate={onCreate}><MobileList>{sale.lines.length ? sale.lines.map((item) => <MobileCard key={item.id} eyebrow={item.external_sku ? `SKU · ${item.external_sku}` : "Без внешнего SKU"} title={item.product ? productMap.get(item.product)?.name ?? item.name : item.name || "Не сопоставлен"} details={[["Количество", displayNumber(item.quantity, 4)], ["Выручка", displayNumber(item.net_amount)], ["Себестоимость", displayNumber(item.cost_amount)]]} actions={<Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : <MobileEmpty />}</MobileList><Table headers={["Товар", "Внешний SKU", "Количество", "Выручка", "Себестоимость", ""]}>{sale.lines.length ? sale.lines.map((item) => <tr key={item.id}><Td>{item.product ? productMap.get(item.product)?.name ?? item.name : item.name || "Не сопоставлен"}</Td><Td>{item.external_sku || "—"}</Td><Td>{displayNumber(item.quantity, 4)}</Td><Td>{displayNumber(item.net_amount)}</Td><Td>{displayNumber(item.cost_amount)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={6} />}</Table></Section>; }
function CostsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceCost[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceCost>) { return <Section title="Расходы" subtitle="Комиссии, логистика, реклама и прочие затраты" onCreate={onCreate}><MobileList>{items.length ? items.map((item) => <MobileCard key={item.id} eyebrow={sourceMap.get(item.source)?.name ?? item.source_code} title={item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"} badges={<Badge>{costTypeLabels[item.cost_type] ?? item.cost_type}</Badge>} details={[["Сумма", `${displayNumber(item.amount)} ${item.currency}`], ["Канал", item.channel || "—"], ["Дата", displayDate(item.occurred_at)]]} actions={<Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : <MobileEmpty />}</MobileList><Table headers={["Товар", "Источник", "Тип", "Канал", "Сумма", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{costTypeLabels[item.cost_type] ?? item.cost_type}</Td><Td>{item.channel || "—"}</Td><Td>{displayNumber(item.amount)} {item.currency}</Td><Td>{displayDate(item.occurred_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={7} />}</Table></Section>; }
function StockTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceStockSnapshot[]; productMap: Map<string, CommerceProduct>; sourceMap: Map<number, SourceSystem> } & TableActions<string, CommerceStockSnapshot>) { return <Section title="Снимки остатков" subtitle="Наблюдения по складам и источникам" onCreate={onCreate}><MobileList>{items.length ? items.map((item) => <MobileCard key={item.id} eyebrow={sourceMap.get(item.source)?.name ?? item.source_code} title={productMap.get(item.product)?.name ?? item.product} badges={<Badge>{item.warehouse_name || item.warehouse_external_id}</Badge>} details={[["Количество", displayNumber(item.quantity, 4)], ["Резерв", displayNumber(item.reserved, 4)], ["Доступно", displayNumber(item.available, 4)], ["Дата", displayDate(item.captured_at)]]} actions={<Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} />} />) : <MobileEmpty />}</MobileList><Table headers={["Товар", "Источник", "Склад", "Количество", "Резерв", "Доступно", "Дата", ""]}>{items.length ? items.map((item) => <tr key={item.id}><Td>{productMap.get(item.product)?.name ?? item.product}</Td><Td>{sourceMap.get(item.source)?.name ?? item.source_code}</Td><Td>{item.warehouse_name || item.warehouse_external_id}</Td><Td>{displayNumber(item.quantity, 4)}</Td><Td>{displayNumber(item.reserved, 4)}</Td><Td>{displayNumber(item.available, 4)}</Td><Td>{displayDate(item.captured_at)}</Td><Td><Actions onEdit={() => onEdit(item)} onDelete={() => onDelete(item.id)} /></Td></tr>) : <EmptyRow columns={8} />}</Table></Section>; }
function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return <div className="commerce-management-table max-w-full overflow-x-auto overscroll-x-contain"><table className="w-full min-w-[760px] text-left text-sm"><thead className="sticky top-0 z-10 bg-[var(--surface-secondary)] text-xs uppercase tracking-wide text-[var(--muted-foreground)]"><tr>{headers.map((header, index) => <th key={`${header}-${index}`} className="px-4 py-3 font-medium">{header}</th>)}</tr></thead><tbody className="divide-y divide-[var(--border-subtle)]">{children}</tbody></table></div>; }
function Td({ children }: { children: ReactNode }) { return <td className="max-w-sm break-words px-4 py-3 align-middle">{children}</td>; }
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 <label className={className}><span className="text-xs font-medium text-[var(--muted-foreground)]">{label}{required ? " *" : ""}</span>{multiline ? <textarea required={required} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} rows={4} className={controlClass} /> : <input required={required} type={type} step={step} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className={controlClass} />}</label>; }

View File

@ -337,7 +337,7 @@ export function ProductConnectionsContent({
const wildberriesVisibleStats = wildberriesCurrentRun || wildberries?.last_stats;
return (
<div className={embedded ? "space-y-5" : "mx-auto w-full max-w-6xl space-y-5 p-4 sm:p-6"}>
<div className={`commerce-connections ${embedded ? "space-y-5" : "mx-auto w-full max-w-6xl space-y-5 p-4 sm:p-6"}`}>
{!embedded ? <header className="flex flex-wrap items-center justify-between gap-3">
<div>
<Link href="/products/manage" className="mb-2 inline-flex items-center gap-2 text-sm app-text-muted hover:text-[var(--foreground)]"><ArrowLeft className="h-4 w-4" /> К управлению</Link>
@ -348,11 +348,11 @@ export function ProductConnectionsContent({
{error ? <div className="app-feedback-danger rounded-xl p-4 text-sm">{error}</div> : null}
<section className="app-surface rounded-2xl p-5 sm:p-6">
<section className="commerce-connection-card app-surface min-w-0 overflow-hidden rounded-2xl">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex gap-3">
<div className="flex min-w-0 gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-500/10 text-sky-500"><Store className="h-5 w-5" /></div>
<div>
<div className="min-w-0">
<h2 className="font-semibold">МойСклад</h2>
<p className="mt-1 max-w-2xl text-sm app-text-muted">Массовый импорт товаров, модификаций и текущих остатков с сохранением внешних идентификаторов.</p>
</div>
@ -362,7 +362,7 @@ export function ProductConnectionsContent({
{loading ? <div className="mt-6 flex items-center gap-2 text-sm app-text-muted"><Loader2 className="h-4 w-4 animate-spin" /> Загрузка…</div> : moysklad && !editing ? (
<div className="mt-6 space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
<div className="commerce-connection-metrics commerce-connection-metrics-six">
<Metric icon={PackageCheck} label="Товаров обработано" value={visibleStats?.processed ?? 0} />
<Metric icon={CloudDownload} label="Создано товаров" value={visibleStats?.created_products ?? 0} />
<Metric icon={CheckCircle2} label="Сопоставлено" value={visibleStats?.matched_products ?? 0} />
@ -386,19 +386,19 @@ export function ProductConnectionsContent({
</div>
) : null}
{moysklad.last_error ? <div className="app-feedback-danger flex gap-2 rounded-xl p-4 text-sm"><TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {moysklad.last_error}</div> : null}
<dl className="grid gap-3 text-sm sm:grid-cols-3">
<dl className="commerce-connection-meta text-sm">
<div><dt className="text-xs app-text-muted">Учётная запись</dt><dd className="mt-1">{moysklad.account_name || "—"}</dd></div>
<div><dt className="text-xs app-text-muted">Последняя проверка</dt><dd className="mt-1">{displayDate(moysklad.last_tested_at)}</dd></div>
<div><dt className="text-xs app-text-muted">Последний импорт</dt><dd className="mt-1">{displayDate(moysklad.last_sync_finished_at)}</dd></div>
</dl>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-[var(--surface-secondary)] p-4">
<div className="commerce-connection-schedule rounded-xl bg-[var(--surface-secondary)] p-4">
<label className="flex items-center gap-3 text-sm"><input type="checkbox" checked={moysklad.auto_sync} disabled={Boolean(busy)} onChange={(event) => void updateSchedule(moysklad, { auto_sync: event.target.checked, sync_interval_minutes: moysklad.sync_interval_minutes })} /><span><span className="block font-medium">Постоянная синхронизация</span><span className="block text-xs app-text-muted">Сервер запускает импорт автоматически</span></span></label>
<div className="flex flex-wrap items-center gap-3">
<div className="commerce-connection-schedule-controls">
<label className="flex items-center gap-2 text-sm app-text-muted"><Clock3 className="h-4 w-4" /><span>Каждые</span><select value={moysklad.sync_interval_minutes} disabled={!moysklad.auto_sync || Boolean(busy)} onChange={(event) => void updateSchedule(moysklad, { auto_sync: true, sync_interval_minutes: Number(event.target.value) })} className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-2 py-1.5 text-sm text-[var(--foreground)] disabled:opacity-50"><option value={5}>5 минут</option><option value={15}>15 минут</option><option value={30}>30 минут</option><option value={60}>1 час</option><option value={360}>6 часов</option><option value={1440}>сутки</option></select></label>
<span className="text-xs app-text-muted">Следующий запуск: {moysklad.auto_sync ? displayDate(moysklad.next_sync_at) : "отключён"}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="commerce-connection-actions">
<button type="button" disabled={Boolean(syncActive || busy)} onClick={() => void startSync(moysklad)} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50">{syncActive ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {syncActive ? "Импорт выполняется" : "Синхронизировать всё"}</button>
<button type="button" disabled={Boolean(busy)} onClick={() => { setAuthType(moysklad.auth_type); setEditing(true); }} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><KeyRound className="h-4 w-4" /> Обновить доступ</button>
<button type="button" disabled={Boolean(syncActive || busy)} onClick={() => void disconnect(moysklad)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-rose-500 disabled:opacity-50"><Trash2 className="h-4 w-4" /> Отключить</button>
@ -407,7 +407,7 @@ export function ProductConnectionsContent({
</div>
) : (
<form onSubmit={connectAndImport} className="mt-6 space-y-5">
<div className="grid gap-5 lg:grid-cols-[minmax(240px,0.65fr)_minmax(360px,1.35fr)] lg:items-end">
<div className="commerce-connection-auth-grid">
<div>
<p className="mb-2 text-xs font-medium app-text-muted">Способ авторизации</p>
<div className="flex flex-wrap gap-2">
@ -417,9 +417,9 @@ export function ProductConnectionsContent({
</div>
{authType === "token" ? <Field label="Access token МойСклад" type="password" value={token} onChange={setToken} placeholder="Токен без слова Bearer" /> : <div className="grid gap-4 sm:grid-cols-2"><Field label="Логин" value={login} onChange={setLogin} /><Field label="Пароль" type="password" value={password} onChange={setPassword} /></div>}
</div>
<div className="flex flex-col gap-4 border-t border-[var(--border-subtle)] pt-5 sm:flex-row sm:items-center sm:justify-between">
<div className="commerce-connection-form-footer border-t border-[var(--border-subtle)] pt-5">
<div className="flex max-w-xl items-start gap-2 text-sm app-text-muted"><ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" /><span>Реквизиты шифруются на сервере и никогда не возвращаются в браузер.</span></div>
<div className="flex shrink-0 flex-wrap gap-2">
<div className="commerce-connection-form-actions">
{moysklad ? <button type="button" onClick={() => setEditing(false)} className="app-action-secondary rounded-lg px-4 py-2 text-sm">Отмена</button> : null}
<button type="submit" disabled={busy === "connect"} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm disabled:opacity-50">{busy === "connect" ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} Проверить и импортировать всё</button>
</div>
@ -428,11 +428,11 @@ export function ProductConnectionsContent({
)}
</section>
<section className="app-surface rounded-2xl p-5 sm:p-6">
<section className="commerce-connection-card app-surface min-w-0 overflow-hidden rounded-2xl">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex gap-3">
<div className="flex min-w-0 gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-violet-500/10 text-violet-400"><Store className="h-5 w-5" /></div>
<div>
<div className="min-w-0">
<h2 className="font-semibold">Wildberries</h2>
<p className="mt-1 max-w-2xl text-sm app-text-muted">Продажи и возвраты из финансового отчёта, комиссии, эквайринг, логистика, хранение и удержания по товарам.</p>
</div>
@ -442,7 +442,7 @@ export function ProductConnectionsContent({
{loading ? <div className="mt-6 flex items-center gap-2 text-sm app-text-muted"><Loader2 className="h-4 w-4 animate-spin" /> Загрузка…</div> : wildberries && !wildberriesEditing ? (
<div className="mt-6 space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="commerce-connection-metrics commerce-connection-metrics-four">
<Metric icon={CloudDownload} label="Строк отчёта" value={wildberriesVisibleStats?.processed ?? 0} />
<Metric icon={CheckCircle2} label="Сопоставлено с товарами" value={wildberriesVisibleStats?.matched_products ?? 0} />
<Metric icon={PackageCheck} label="Продаж и возвратов" value={wildberriesVisibleStats?.sales ?? 0} />
@ -451,16 +451,16 @@ export function ProductConnectionsContent({
{(wildberries.last_stats.unmatched_products ?? 0) > 0 ? <div className="app-feedback-warning flex gap-2 rounded-xl p-4 text-sm"><TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" /><span>Не сопоставлено с каталогом: {wildberries.last_stats.unmatched_products}. Строки сохранены; после привязки товаров повторный импорт дополнит аналитику.</span></div> : null}
{wildberries.last_stats.report_complete === false ? <div className="app-feedback-warning flex gap-2 rounded-xl p-4 text-sm"><Clock3 className="mt-0.5 h-4 w-4 shrink-0" /><span>Страница отчёта загружена. Курсор сохранён: следующий запуск продолжит импорт и подтвердит завершение ответом Wildberries.</span></div> : null}
{wildberries.last_error ? <div className="app-feedback-danger flex gap-2 rounded-xl p-4 text-sm"><TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {wildberries.last_error}</div> : null}
<dl className="grid gap-3 text-sm sm:grid-cols-3">
<dl className="commerce-connection-meta text-sm">
<div><dt className="text-xs app-text-muted">Магазин</dt><dd className="mt-1">{wildberries.account_name || "—"}</dd></div>
<div><dt className="text-xs app-text-muted">Последняя проверка</dt><dd className="mt-1">{displayDate(wildberries.last_tested_at)}</dd></div>
<div><dt className="text-xs app-text-muted">Последний импорт</dt><dd className="mt-1">{displayDate(wildberries.last_sync_finished_at)}</dd></div>
</dl>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-[var(--surface-secondary)] p-4">
<div className="commerce-connection-schedule rounded-xl bg-[var(--surface-secondary)] p-4">
<label className="flex items-center gap-3 text-sm"><input type="checkbox" checked={wildberries.auto_sync} disabled={Boolean(busy)} onChange={(event) => void updateSchedule(wildberries, { auto_sync: event.target.checked, sync_interval_minutes: wildberries.sync_interval_minutes })} /><span><span className="block font-medium">Автоматический финансовый импорт</span><span className="block text-xs app-text-muted">Повторно проверяет последние 30 дней и учитывает корректировки WB</span></span></label>
<label className="flex items-center gap-2 text-sm app-text-muted"><Clock3 className="h-4 w-4" /><span>Каждые</span><select value={wildberries.sync_interval_minutes} disabled={!wildberries.auto_sync || Boolean(busy)} onChange={(event) => void updateSchedule(wildberries, { auto_sync: true, sync_interval_minutes: Number(event.target.value) })} className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-2 py-1.5 text-sm text-[var(--foreground)] disabled:opacity-50"><option value={60}>1 час</option><option value={360}>6 часов</option><option value={720}>12 часов</option><option value={1440}>сутки</option></select></label>
</div>
<div className="flex flex-wrap gap-2">
<div className="commerce-connection-actions">
<button type="button" disabled={Boolean(wildberriesSyncActive || busy)} onClick={() => void startSync(wildberries)} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50">{wildberriesSyncActive ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {wildberriesSyncActive ? "Импорт выполняется" : "Импортировать отчёт"}</button>
<button type="button" disabled={Boolean(busy)} onClick={() => setWildberriesEditing(true)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"><KeyRound className="h-4 w-4" /> Обновить токен</button>
<button type="button" disabled={Boolean(wildberriesSyncActive || busy)} onClick={() => void disconnect(wildberries)} className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-rose-500 disabled:opacity-50"><Trash2 className="h-4 w-4" /> Отключить</button>
@ -470,9 +470,9 @@ export function ProductConnectionsContent({
) : (
<form onSubmit={connectWildberries} className="mt-6 space-y-5">
<Field label="API-токен Wildberries с категорией «Финансы»" type="password" value={wildberriesToken} onChange={setWildberriesToken} placeholder="Токен без слова Bearer" />
<div className="flex flex-col gap-4 border-t border-[var(--border-subtle)] pt-5 sm:flex-row sm:items-center sm:justify-between">
<div className="commerce-connection-form-footer border-t border-[var(--border-subtle)] pt-5">
<div className="flex max-w-xl items-start gap-2 text-sm app-text-muted"><ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" /><span>Токен шифруется на сервере. Тест сверит магазин и ИНН, а также отдельно подтвердит доступ к категории «Финансы».</span></div>
<div className="flex shrink-0 flex-wrap gap-2">
<div className="commerce-connection-form-actions">
{wildberries ? <button type="button" onClick={() => setWildberriesEditing(false)} className="app-action-secondary rounded-lg px-4 py-2 text-sm">Отмена</button> : null}
<button type="submit" disabled={busy === "wildberries-connect"} className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm disabled:opacity-50">{busy === "wildberries-connect" ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} Проверить и импортировать отчёт</button>
</div>
@ -481,7 +481,7 @@ export function ProductConnectionsContent({
)}
</section>
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<section className="commerce-planned-services">
<PlannedService name="Ozon" description="Начисления, комиссии, логистика и реклама по SKU" />
<PlannedService name="InSales" description="Каталог, варианты и заказы интернет-магазина" />
<PlannedService name="RetailCRM" description="Заказы, каналы, статусы и расходы" />
@ -495,9 +495,9 @@ function Field({ label, value, onChange, type = "text", placeholder }: { label:
}
function Metric({ icon: Icon, label, value }: { icon: typeof Store; label: string; value: number }) {
return <div className="rounded-xl bg-[var(--surface-secondary)] p-3"><Icon className="mb-2 h-4 w-4 text-sky-500" /><p className="text-xl font-semibold">{new Intl.NumberFormat("ru-RU").format(value)}</p><p className="mt-0.5 text-xs app-text-muted">{label}</p></div>;
return <div className="min-w-0 rounded-xl bg-[var(--surface-secondary)] p-3"><Icon className="mb-2 h-4 w-4 text-sky-500" /><p className="text-xl font-semibold">{new Intl.NumberFormat("ru-RU").format(value)}</p><p className="mt-0.5 break-words text-xs app-text-muted">{label}</p></div>;
}
function PlannedService({ name, description }: { name: string; description: string }) {
return <div className="app-surface rounded-xl p-4 opacity-70"><div className="flex items-center justify-between gap-3"><div><h2 className="font-medium">{name}</h2><p className="mt-1 text-sm app-text-muted">{description}</p></div><span className="shrink-0 rounded-full bg-[var(--surface-secondary)] px-2.5 py-1 text-xs app-text-muted">Следующий этап</span></div></div>;
return <div className="app-surface min-w-0 rounded-xl p-4 opacity-70"><div className="commerce-planned-service-content"><div className="min-w-0"><h2 className="font-medium">{name}</h2><p className="mt-1 text-sm app-text-muted">{description}</p></div><span className="shrink-0 self-start rounded-full bg-[var(--surface-secondary)] px-2.5 py-1 text-xs app-text-muted">Следующий этап</span></div></div>;
}

View File

@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { type ReactNode } from "react";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import { useAutoRevealOnScroll } from "@/hooks/useAutoRevealOnScroll";
type AutoRevealStickyHeaderProps = {
children: ReactNode;
@ -18,66 +19,12 @@ export function AutoRevealStickyHeader({
surface = "solid",
}: AutoRevealStickyHeaderProps) {
const { mobileNavPlacement } = useMobileNavPlacement();
const [visible, setVisible] = useState(true);
const [elevated, setElevated] = useState(false);
const lastScrollYRef = useRef(0);
const scrollDeltaRef = useRef(0);
const scrollDirectionRef = useRef<"up" | "down" | null>(null);
useEffect(() => {
lastScrollYRef.current = Math.max(0, window.scrollY);
let frameId: number | null = null;
const update = () => {
frameId = null;
const currentScrollY = Math.max(0, window.scrollY);
const delta = currentScrollY - lastScrollYRef.current;
setElevated(currentScrollY > 8);
if (currentScrollY <= 8) {
setVisible(true);
scrollDeltaRef.current = 0;
scrollDirectionRef.current = null;
lastScrollYRef.current = currentScrollY;
return;
}
if (Math.abs(delta) >= 1) {
const direction = delta > 0 ? "down" : "up";
if (scrollDirectionRef.current !== direction) {
scrollDirectionRef.current = direction;
scrollDeltaRef.current = 0;
}
scrollDeltaRef.current += delta;
if (direction === "down" && scrollDeltaRef.current >= 24 && currentScrollY > 80) {
setVisible(false);
scrollDeltaRef.current = 0;
} else if (direction === "up" && scrollDeltaRef.current <= -12) {
setVisible(true);
scrollDeltaRef.current = 0;
}
}
lastScrollYRef.current = currentScrollY;
};
const handleScroll = () => {
if (frameId !== null) return;
frameId = window.requestAnimationFrame(update);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (frameId !== null) window.cancelAnimationFrame(frameId);
};
}, []);
const { elevated, show, visible } = useAutoRevealOnScroll();
return (
<div
data-auto-reveal-header="true"
onFocusCapture={() => setVisible(true)}
onFocusCapture={show}
className={`sticky z-30 transition-[transform,box-shadow] duration-200 ease-out will-change-transform ${
disableOnDesktop
? "lg:static lg:translate-y-0 lg:bg-transparent lg:shadow-none"

View File

@ -114,7 +114,6 @@ export function HorizontalChipScroller({
startX: event.clientX,
dragged: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
}, []);
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
@ -122,12 +121,13 @@ export function HorizontalChipScroller({
if (!drag || drag.pointerId !== event.pointerId) return;
const distance = event.clientX - drag.startX;
if (!drag.dragged && Math.abs(distance) < 5) return;
if (!drag.dragged && Math.abs(distance) < 8) return;
if (!drag.dragged) {
drag.dragged = true;
suppressClickRef.current = true;
setIsMouseDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
}
event.preventDefault();

View File

@ -0,0 +1,65 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export function useAutoRevealOnScroll() {
const [visible, setVisible] = useState(true);
const [elevated, setElevated] = useState(false);
const lastScrollYRef = useRef(0);
const scrollDeltaRef = useRef(0);
const scrollDirectionRef = useRef<"up" | "down" | null>(null);
useEffect(() => {
lastScrollYRef.current = Math.max(0, window.scrollY);
let frameId: number | null = null;
const update = () => {
frameId = null;
const currentScrollY = Math.max(0, window.scrollY);
const delta = currentScrollY - lastScrollYRef.current;
setElevated(currentScrollY > 8);
if (currentScrollY <= 8) {
setVisible(true);
scrollDeltaRef.current = 0;
scrollDirectionRef.current = null;
lastScrollYRef.current = currentScrollY;
return;
}
if (Math.abs(delta) >= 1) {
const direction = delta > 0 ? "down" : "up";
if (scrollDirectionRef.current !== direction) {
scrollDirectionRef.current = direction;
scrollDeltaRef.current = 0;
}
scrollDeltaRef.current += delta;
if (direction === "down" && scrollDeltaRef.current >= 24 && currentScrollY > 80) {
setVisible(false);
scrollDeltaRef.current = 0;
} else if (direction === "up" && scrollDeltaRef.current <= -12) {
setVisible(true);
scrollDeltaRef.current = 0;
}
}
lastScrollYRef.current = currentScrollY;
};
const handleScroll = () => {
if (frameId !== null) return;
frameId = window.requestAnimationFrame(update);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (frameId !== null) window.cancelAnimationFrame(frameId);
};
}, []);
const show = useCallback(() => setVisible(true), []);
return { elevated, show, visible };
}