From 1e0146b0eaa53f7bfd6050c59bbd29069b62d2dd Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 17 Aug 2026 18:38:22 +0300 Subject: [PATCH] Add frontend commerce management --- backend/api/v1/commerce/serializers.py | 41 +++ backend/api/v1/commerce/urls.py | 2 + backend/api/v1/commerce/viewsets.py | 17 +- backend/commerce/README.md | 8 +- backend/tests/commerce/test_api.py | 65 +++- frontend/src/app/products/manage/page.tsx | 429 ++++++++++++++++++++++ frontend/src/app/products/page.tsx | 43 ++- frontend/src/lib/api/commerce.ts | 114 ++++++ frontend/src/types/commerce.ts | 154 ++++++++ 9 files changed, 855 insertions(+), 18 deletions(-) create mode 100644 frontend/src/app/products/manage/page.tsx diff --git a/backend/api/v1/commerce/serializers.py b/backend/api/v1/commerce/serializers.py index d7787dc5..e0d8dfd4 100644 --- a/backend/api/v1/commerce/serializers.py +++ b/backend/api/v1/commerce/serializers.py @@ -77,6 +77,7 @@ class SaleLineSerializer(serializers.ModelSerializer): model = SaleLine fields = [ "id", + "sale", "product", "external_id", "external_product_id", @@ -90,6 +91,17 @@ class SaleLineSerializer(serializers.ModelSerializer): "metadata", ] + def validate(self, attrs): + gross = attrs.get("gross_amount", getattr(self.instance, "gross_amount", 0)) + discount = attrs.get( + "discount_amount", getattr(self.instance, "discount_amount", 0) + ) + if discount > gross: + raise serializers.ValidationError( + {"discount_amount": "Скидка не может превышать сумму позиции."} + ) + return attrs + class SaleSerializer(serializers.ModelSerializer): source_code = serializers.CharField(source="source.code", read_only=True) @@ -137,6 +149,35 @@ class CostSerializer(serializers.ModelSerializer): "metadata", "created_at", ] + validators = [] + + def validate(self, attrs): + product = attrs.get("product", getattr(self.instance, "product", None)) + sale_line = attrs.get("sale_line", getattr(self.instance, "sale_line", None)) + if product is None and sale_line is None: + raise serializers.ValidationError( + "Нужно указать товар или позицию продажи." + ) + external_id = attrs.get( + "external_id", getattr(self.instance, "external_id", "") + ) + if external_id: + source = attrs.get("source", getattr(self.instance, "source", None)) + cost_type = attrs.get( + "cost_type", getattr(self.instance, "cost_type", None) + ) + duplicate = Cost.objects.filter( + source=source, + external_id=external_id, + cost_type=cost_type, + ) + if self.instance: + duplicate = duplicate.exclude(pk=self.instance.pk) + if duplicate.exists(): + raise serializers.ValidationError( + {"external_id": "Расход с таким внешним ID уже существует."} + ) + return attrs class StockSnapshotSerializer(serializers.ModelSerializer): diff --git a/backend/api/v1/commerce/urls.py b/backend/api/v1/commerce/urls.py index fd5cd39b..98894919 100644 --- a/backend/api/v1/commerce/urls.py +++ b/backend/api/v1/commerce/urls.py @@ -5,6 +5,7 @@ from .viewsets import ( ProductBindingViewSet, ProductViewSet, SaleViewSet, + SaleLineViewSet, SourceSystemViewSet, StockSnapshotViewSet, ) @@ -16,6 +17,7 @@ router.register(r"products", ProductViewSet, basename="product") router.register(r"sources", SourceSystemViewSet, basename="source") router.register(r"product-bindings", ProductBindingViewSet, basename="productbinding") router.register(r"sales", SaleViewSet, basename="sale") +router.register(r"sale-lines", SaleLineViewSet, basename="saleline") router.register(r"costs", CostViewSet, basename="cost") router.register(r"stock-snapshots", StockSnapshotViewSet, basename="stocksnapshot") diff --git a/backend/api/v1/commerce/viewsets.py b/backend/api/v1/commerce/viewsets.py index 1247a45b..9e71545f 100644 --- a/backend/api/v1/commerce/viewsets.py +++ b/backend/api/v1/commerce/viewsets.py @@ -9,6 +9,7 @@ from commerce.models import ( Product, ProductBinding, Sale, + SaleLine, SourceSystem, StockSnapshot, ) @@ -21,6 +22,7 @@ from .serializers import ( ProductMetricsQuerySerializer, ProductSerializer, SaleSerializer, + SaleLineSerializer, SourceSystemSerializer, StockSnapshotSerializer, ) @@ -77,7 +79,7 @@ class ProductBindingViewSet(viewsets.ModelViewSet): search_fields = ["external_id", "external_sku", "product__sku", "product__name"] -class SaleViewSet(viewsets.ReadOnlyModelViewSet): +class SaleViewSet(viewsets.ModelViewSet): queryset = Sale.objects.select_related("source", "source_record").prefetch_related( "lines__product" ) @@ -101,7 +103,16 @@ class SaleViewSet(viewsets.ReadOnlyModelViewSet): ordering = ["-occurred_at"] -class CostViewSet(viewsets.ReadOnlyModelViewSet): +class SaleLineViewSet(viewsets.ModelViewSet): + queryset = SaleLine.objects.select_related("sale", "product", "sale__source") + serializer_class = SaleLineSerializer + permission_classes = [CommerceAccessPermission] + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_fields = ["sale", "product"] + search_fields = ["external_id", "external_product_id", "external_sku", "name"] + + +class CostViewSet(viewsets.ModelViewSet): queryset = Cost.objects.select_related("source", "product", "sale_line") serializer_class = CostSerializer permission_classes = [CommerceAccessPermission] @@ -111,7 +122,7 @@ class CostViewSet(viewsets.ReadOnlyModelViewSet): ordering = ["-occurred_at"] -class StockSnapshotViewSet(viewsets.ReadOnlyModelViewSet): +class StockSnapshotViewSet(viewsets.ModelViewSet): queryset = StockSnapshot.objects.select_related("source", "product") serializer_class = StockSnapshotSerializer permission_classes = [CommerceAccessPermission] diff --git a/backend/commerce/README.md b/backend/commerce/README.md index 6173912d..9873c6c9 100644 --- a/backend/commerce/README.md +++ b/backend/commerce/README.md @@ -41,9 +41,11 @@ The authenticated API is mounted at `/api/v1/commerce/`: - `sources/` - `product-bindings/` - `sales/` +- `sale-lines/` - `costs/` - `stock-snapshots/` -All employees may read. Writes to the canonical catalogue and bindings require -staff status or the corresponding Django model permission. Normalized fact -endpoints are read-only; integrations write them through application services. +All employees may read. Writes require staff status or the corresponding +Django model permission. The frontend management screen can bootstrap and +correct normalized records manually; integrations should still write them +through application services so raw provenance and idempotency are preserved. diff --git a/backend/tests/commerce/test_api.py b/backend/tests/commerce/test_api.py index 12d9aa59..d7c945c9 100644 --- a/backend/tests/commerce/test_api.py +++ b/backend/tests/commerce/test_api.py @@ -5,7 +5,7 @@ from django.urls import reverse from django.utils import timezone from rest_framework import status -from commerce.models import Product, Sale, SaleLine, SourceSystem +from commerce.models import Cost, Product, Sale, SaleLine, SourceSystem, StockSnapshot pytestmark = pytest.mark.django_db @@ -46,6 +46,69 @@ def test_staff_can_create_product(api_client, user_factory): assert Product.objects.filter(sku="NEW").exists() +def test_staff_can_manage_normalized_commerce_facts(api_client, user_factory): + user = user_factory(staff=True) + source = SourceSystem.objects.create(code="moysklad", name="МойСклад") + product = Product.objects.create(sku="FISHKA", name="Фишка") + api_client.force_authenticate(user=user) + + sale_response = api_client.post( + reverse("api:v1:commerce:sale-list"), + { + "source": source.pk, + "external_id": "order-1", + "occurred_at": timezone.now().isoformat(), + }, + format="json", + ) + assert sale_response.status_code == status.HTTP_201_CREATED + + line_response = api_client.post( + reverse("api:v1:commerce:saleline-list"), + { + "sale": sale_response.data["id"], + "product": product.pk, + "external_id": "line-1", + "quantity": "2", + "gross_amount": "500.00", + "discount_amount": "50.00", + "cost_amount": "200.00", + }, + format="json", + ) + assert line_response.status_code == status.HTTP_201_CREATED + + cost_response = api_client.post( + reverse("api:v1:commerce:cost-list"), + { + "source": source.pk, + "product": product.pk, + "cost_type": "commission", + "amount": "25.00", + "occurred_at": timezone.now().isoformat(), + }, + format="json", + ) + assert cost_response.status_code == status.HTTP_201_CREATED, cost_response.data + + stock_response = api_client.post( + reverse("api:v1:commerce:stocksnapshot-list"), + { + "source": source.pk, + "product": product.pk, + "warehouse_external_id": "main", + "quantity": "10", + "reserved": "2", + "captured_at": timezone.now().isoformat(), + }, + format="json", + ) + assert stock_response.status_code == status.HTTP_201_CREATED + assert SaleLine.objects.filter(pk=line_response.data["id"]).exists() + assert Cost.objects.filter(pk=cost_response.data["id"]).exists() + assert StockSnapshot.objects.filter(pk=stock_response.data["id"]).exists() + + def test_product_analytics_endpoint(api_client, user_factory): user = user_factory() source = SourceSystem.objects.create(code="insales", name="InSales") diff --git a/frontend/src/app/products/manage/page.tsx b/frontend/src/app/products/manage/page.tsx new file mode 100644 index 00000000..4e138908 --- /dev/null +++ b/frontend/src/app/products/manage/page.tsx @@ -0,0 +1,429 @@ +"use client"; + +import { AppShell } from "@/components/AppShell"; +import { Modal } from "@/components/ui"; +import { useUser } from "@/contexts/UserContext"; +import { apiClient } from "@/lib/api"; +import type { + CommerceCost, + CommerceProduct, + CommerceSale, + CommerceSaleLine, + CommerceStockSnapshot, + ProductBinding, + SourceSystem, +} from "@/types/commerce"; +import { + ArrowLeft, + Boxes, + CircleDollarSign, + Database, + Link2, + ListTree, + Pencil, + Plus, + ReceiptText, + RefreshCw, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +type Tab = "products" | "sources" | "bindings" | "sales" | "costs" | "stock"; +type EditorKind = "product" | "source" | "binding" | "sale" | "line" | "cost" | "stock"; +type Editor = { kind: EditorKind; id?: string | number } | null; +type FormState = Record; + +const tabs: Array<{ id: Tab; label: string; icon: typeof Boxes }> = [ + { id: "products", label: "Товары", icon: Boxes }, + { id: "sources", label: "Источники", icon: Database }, + { id: "bindings", label: "Привязки", icon: Link2 }, + { id: "sales", label: "Продажи", icon: ReceiptText }, + { id: "costs", label: "Расходы", icon: CircleDollarSign }, + { id: "stock", label: "Остатки", icon: ListTree }, +]; + +const costTypeLabels: Record = { + commission: "Комиссия", + delivery: "Доставка", + storage: "Хранение", + advertising: "Реклама", + acquiring: "Эквайринг", + return: "Возврат", + other: "Прочее", +}; + +function localDateTime(value?: string | null) { + const date = value ? new Date(value) : new Date(); + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 16); +} + +function apiDateTime(value: string) { + return new Date(value).toISOString(); +} + +function displayDate(value: string) { + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(value)); +} + +function displayNumber(value: string, digits = 2) { + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: digits }).format( + Number(value), + ); +} + +export default function CommerceManagementPage() { + const { user, loading: userLoading } = useUser(); + const [tab, setTab] = useState("products"); + const [products, setProducts] = useState([]); + const [sources, setSources] = useState([]); + const [bindings, setBindings] = useState([]); + const [sales, setSales] = useState([]); + const [costs, setCosts] = useState([]); + const [stock, setStock] = useState([]); + const [selectedSaleId, setSelectedSaleId] = useState(null); + const [editor, setEditor] = useState(null); + const [form, setForm] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const permissions = user?.auth?.permissions_by_app?.commerce ?? []; + const canManage = Boolean( + user?.auth?.is_staff || + user?.auth?.is_superuser || + permissions.some( + (permission) => + permission.startsWith("add_") || + permission.startsWith("change_") || + permission.startsWith("delete_"), + ), + ); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [productData, sourceData, bindingData, saleData, costData, stockData] = + await Promise.all([ + apiClient.getCommerceProducts({ page_size: 200, ordering: "name" }), + apiClient.getCommerceSources(), + apiClient.getCommerceBindings({ page_size: 200 }), + apiClient.getCommerceSales({ page_size: 200, ordering: "-occurred_at" }), + apiClient.getCommerceCosts({ page_size: 200, ordering: "-occurred_at" }), + apiClient.getCommerceStockSnapshots({ + page_size: 200, + ordering: "-captured_at", + }), + ]); + setProducts(productData.results); + setSources(sourceData.results); + setBindings(bindingData.results); + setSales(saleData.results); + setCosts(costData.results); + setStock(stockData.results); + setSelectedSaleId((current) => + current && saleData.results.some((sale) => sale.id === current) + ? current + : null, + ); + } catch (requestError) { + setError( + requestError instanceof Error + ? requestError.message + : "Не удалось загрузить данные.", + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!canManage) return; + const refreshTimer = window.setTimeout(() => void loadData(), 0); + return () => window.clearTimeout(refreshTimer); + }, [canManage, loadData]); + + const productMap = useMemo( + () => new Map(products.map((product) => [product.id, product])), + [products], + ); + const sourceMap = useMemo( + () => new Map(sources.map((source) => [source.id, source])), + [sources], + ); + const selectedSale = sales.find((sale) => sale.id === selectedSaleId) ?? null; + + function openCreate(kind: EditorKind) { + const now = localDateTime(); + const defaults: Record = { + product: { kind: "product", sku: "", name: "", barcode: "", description: "", parent: "", is_active: true }, + source: { code: "", name: "", is_active: true }, + binding: { product: "", source: "", external_id: "", external_sku: "", is_primary: false }, + sale: { source: "", external_id: "", number: "", event_type: "sale", channel: "", status: "", currency: "RUB", occurred_at: now, counts_as_sale: true }, + line: { sale: selectedSaleId ?? "", product: "", external_id: "", external_product_id: "", external_sku: "", name: "", quantity: "1", gross_amount: "0", discount_amount: "0", cost_amount: "0" }, + cost: { source: "", product: "", external_id: "", channel: "", cost_type: "commission", amount: "0", currency: "RUB", occurred_at: now }, + stock: { source: "", product: "", warehouse_external_id: "", warehouse_name: "", quantity: "0", reserved: "0", in_transit: "0", captured_at: now }, + }; + setForm(defaults[kind]); + setEditor({ kind }); + } + + function openEdit(kind: EditorKind, item: unknown) { + if (kind === "product") { + const value = item as CommerceProduct; + setForm({ kind: value.kind, sku: value.sku, name: value.name, barcode: value.barcode, description: value.description, parent: value.parent ?? "", is_active: value.is_active }); + setEditor({ kind, id: value.id }); + } else if (kind === "source") { + const value = item as SourceSystem; + setForm({ code: value.code, name: value.name, is_active: value.is_active }); + setEditor({ kind, id: value.id }); + } else if (kind === "binding") { + const value = item as ProductBinding; + setForm({ product: value.product, source: String(value.source), external_id: value.external_id, external_sku: value.external_sku, is_primary: value.is_primary }); + setEditor({ kind, id: value.id }); + } else if (kind === "sale") { + const value = item as CommerceSale; + setForm({ source: String(value.source), external_id: value.external_id, number: value.number, event_type: value.event_type, channel: value.channel, status: value.status, currency: value.currency, occurred_at: localDateTime(value.occurred_at), counts_as_sale: value.counts_as_sale }); + setEditor({ kind, id: value.id }); + } else if (kind === "line") { + const value = item as CommerceSaleLine; + setForm({ sale: value.sale, product: value.product ?? "", external_id: value.external_id, external_product_id: value.external_product_id, external_sku: value.external_sku, name: value.name, quantity: value.quantity, gross_amount: value.gross_amount, discount_amount: value.discount_amount, cost_amount: value.cost_amount }); + setEditor({ kind, id: value.id }); + } else if (kind === "cost") { + const value = item as CommerceCost; + setForm({ source: String(value.source), product: value.product ?? "", external_id: value.external_id, channel: value.channel, cost_type: value.cost_type, amount: value.amount, currency: value.currency, occurred_at: localDateTime(value.occurred_at) }); + setEditor({ kind, id: value.id }); + } else { + const value = item as CommerceStockSnapshot; + setForm({ source: String(value.source), product: value.product, warehouse_external_id: value.warehouse_external_id, warehouse_name: value.warehouse_name, quantity: value.quantity, reserved: value.reserved, in_transit: value.in_transit, captured_at: localDateTime(value.captured_at) }); + setEditor({ kind, id: value.id }); + } + } + + async function submitEditor() { + if (!editor) return; + setSaving(true); + setError(null); + try { + if (editor.kind === "product") { + const payload = { kind: String(form.kind) as CommerceProduct["kind"], sku: String(form.sku).trim(), name: String(form.name).trim(), barcode: String(form.barcode || "").trim(), description: String(form.description || "").trim(), parent: form.parent ? String(form.parent) : null, is_active: Boolean(form.is_active) }; + if (editor.id) await apiClient.updateCommerceProduct(String(editor.id), payload); + else await apiClient.createCommerceProduct(payload); + } else if (editor.kind === "source") { + const payload = { code: String(form.code).trim(), name: String(form.name).trim(), is_active: Boolean(form.is_active) }; + if (editor.id) await apiClient.updateCommerceSource(Number(editor.id), payload); + else await apiClient.createCommerceSource(payload); + } else if (editor.kind === "binding") { + const payload = { product: String(form.product), source: Number(form.source), external_id: String(form.external_id).trim(), external_sku: String(form.external_sku || "").trim(), is_primary: Boolean(form.is_primary) }; + if (editor.id) await apiClient.updateCommerceBinding(String(editor.id), payload); + else await apiClient.createCommerceBinding(payload); + } else if (editor.kind === "sale") { + const payload = { source: Number(form.source), external_id: String(form.external_id).trim(), number: String(form.number || "").trim(), event_type: String(form.event_type) as CommerceSale["event_type"], channel: String(form.channel || "").trim(), status: String(form.status || "").trim(), counts_as_sale: Boolean(form.counts_as_sale), currency: String(form.currency || "RUB").toUpperCase(), occurred_at: apiDateTime(String(form.occurred_at)) }; + if (editor.id) await apiClient.updateCommerceSale(String(editor.id), payload); + else await apiClient.createCommerceSale(payload); + } else if (editor.kind === "line") { + const payload = { sale: String(form.sale), product: form.product ? String(form.product) : null, external_id: String(form.external_id).trim(), external_product_id: String(form.external_product_id || "").trim(), external_sku: String(form.external_sku || "").trim(), name: String(form.name || "").trim(), quantity: String(form.quantity), gross_amount: String(form.gross_amount), discount_amount: String(form.discount_amount), cost_amount: String(form.cost_amount) }; + if (editor.id) await apiClient.updateCommerceSaleLine(String(editor.id), payload); + else await apiClient.createCommerceSaleLine(payload); + } else if (editor.kind === "cost") { + const payload = { source: Number(form.source), product: form.product ? String(form.product) : null, external_id: String(form.external_id || "").trim(), channel: String(form.channel || "").trim(), cost_type: String(form.cost_type), amount: String(form.amount), currency: String(form.currency || "RUB").toUpperCase(), occurred_at: apiDateTime(String(form.occurred_at)) }; + if (editor.id) await apiClient.updateCommerceCost(String(editor.id), payload); + else await apiClient.createCommerceCost(payload); + } else { + const payload = { source: Number(form.source), product: String(form.product), warehouse_external_id: String(form.warehouse_external_id).trim(), warehouse_name: String(form.warehouse_name || "").trim(), quantity: String(form.quantity), reserved: String(form.reserved), in_transit: String(form.in_transit), captured_at: apiDateTime(String(form.captured_at)) }; + if (editor.id) await apiClient.updateCommerceStockSnapshot(String(editor.id), payload); + else await apiClient.createCommerceStockSnapshot(payload); + } + setEditor(null); + await loadData(); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Не удалось сохранить запись."); + } finally { + setSaving(false); + } + } + + async function remove(kind: EditorKind, id: string | number) { + if (!window.confirm("Удалить запись? Это действие нельзя отменить.")) return; + setError(null); + try { + if (kind === "product") await apiClient.deleteCommerceProduct(String(id)); + else if (kind === "source") await apiClient.deleteCommerceSource(Number(id)); + else if (kind === "binding") await apiClient.deleteCommerceBinding(String(id)); + else if (kind === "sale") await apiClient.deleteCommerceSale(String(id)); + else if (kind === "line") await apiClient.deleteCommerceSaleLine(String(id)); + else if (kind === "cost") await apiClient.deleteCommerceCost(String(id)); + else await apiClient.deleteCommerceStockSnapshot(String(id)); + await loadData(); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Не удалось удалить запись."); + } + } + + if (userLoading) { + return
Проверка прав…
; + } + + if (!canManage) { + return ( + +
+
У вас нет прав на управление товарной аналитикой.
+
+
+ ); + } + + return ( + +
+
+
+ К каталогу +

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

+

Ручное управление справочниками и нормализованными фактами.

+
+ +
+ + {error ?
{error}
: null} + + + + {loading ?
Загрузка…
: ( + <> + {tab === "products" ? openCreate("product")} onEdit={(item) => openEdit("product", item)} onDelete={(id) => void remove("product", id)} /> : null} + {tab === "sources" ? openCreate("source")} onEdit={(item) => openEdit("source", item)} onDelete={(id) => void remove("source", id)} /> : null} + {tab === "bindings" ? openCreate("binding")} onEdit={(item) => openEdit("binding", item)} onDelete={(id) => void remove("binding", id)} /> : null} + {tab === "sales" ? openCreate("sale")} onEdit={(item) => openEdit("sale", item)} onDelete={(id) => void remove("sale", id)} /> : null} + {tab === "sales" && selectedSale ? openCreate("line")} onEdit={(item) => openEdit("line", item)} onDelete={(id) => void remove("line", id)} /> : null} + {tab === "costs" ? openCreate("cost")} onEdit={(item) => openEdit("cost", item)} onDelete={(id) => void remove("cost", id)} /> : null} + {tab === "stock" ? openCreate("stock")} onEdit={(item) => openEdit("stock", item)} onDelete={(id) => void remove("stock", id)} /> : null} + + )} +
+ + setEditor(null)} title={editorTitle(editor)} size="lg" footer={
}> + {editor ?
{ event.preventDefault(); void submitEditor(); }}> : null} +
+
+ ); +} + +function editorTitle(editor: Editor) { + if (!editor) return ""; + const labels: Record = { product: "товар", source: "источник", binding: "привязку", sale: "продажу", line: "позицию продажи", cost: "расход", stock: "снимок остатка" }; + return `${editor.id ? "Изменить" : "Добавить"} ${labels[editor.kind]}`; +} + +function EditorFields({ kind, editorId, form, setForm, products, sources, sales }: { kind: EditorKind; editorId?: string | number; form: FormState; setForm: (value: FormState) => void; products: CommerceProduct[]; sources: SourceSystem[]; sales: CommerceSale[] }) { + const set = (name: string, value: string | boolean) => setForm({ ...form, [name]: value }); + const sourceOptions = sources.map((source) => ({ value: String(source.id), label: `${source.name} (${source.code})` })); + const productOptions = products.filter((product) => kind !== "product" || product.id !== editorId).map((product) => ({ value: product.id, label: `${product.sku} — ${product.name}` })); + return
+ {kind === "product" ? <> + set("sku", value)} /> + set("name", value)} /> + set("kind", value)} options={[{ value: "product", label: "Товар" }, { value: "service", label: "Услуга" }, { value: "bundle", label: "Комплект" }]} /> + set("parent", value)} options={productOptions} emptyLabel="Без родителя" /> + set("barcode", value)} /> + set("is_active", value)} /> + set("description", value)} multiline className="sm:col-span-2" /> + : null} + {kind === "source" ? <> + set("code", value)} placeholder="moysklad" /> + set("name", value)} /> + set("is_active", value)} /> + : null} + {kind === "binding" ? <> + set("product", value)} options={productOptions} /> + set("source", value)} options={sourceOptions} /> + set("external_id", value)} /> + set("external_sku", value)} /> + set("is_primary", value)} /> + : null} + {kind === "sale" ? <> + set("source", value)} options={sourceOptions} /> + set("external_id", value)} /> + set("number", value)} /> + set("event_type", value)} options={[{ value: "sale", label: "Продажа" }, { value: "return", label: "Возврат" }]} /> + set("channel", value)} placeholder="site, ozon, avito" /> + set("status", value)} /> + set("currency", value)} /> + set("occurred_at", value)} /> + set("counts_as_sale", value)} /> + : null} + {kind === "line" ? <> + set("sale", value)} options={sales.map((sale) => ({ value: sale.id, label: sale.number || sale.external_id }))} /> + set("product", value)} options={productOptions} /> + set("external_id", value)} /> + set("name", value)} /> + set("external_product_id", value)} /> + set("external_sku", value)} /> + set("quantity", value)} /> + set("gross_amount", value)} /> + set("discount_amount", value)} /> + set("cost_amount", value)} /> + : null} + {kind === "cost" ? <> + set("source", value)} options={sourceOptions} /> + set("product", value)} options={productOptions} /> + set("cost_type", value)} options={Object.entries(costTypeLabels).map(([value, label]) => ({ value, label }))} /> + set("channel", value)} /> + set("amount", value)} /> + set("currency", value)} /> + set("occurred_at", value)} /> + set("external_id", value)} /> + : null} + {kind === "stock" ? <> + set("source", value)} options={sourceOptions} /> + set("product", value)} options={productOptions} /> + set("warehouse_external_id", value)} /> + set("warehouse_name", value)} /> + set("quantity", value)} /> + set("reserved", value)} /> + set("in_transit", value)} /> + set("captured_at", value)} /> + : null} +
; +} + +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}
; +} + +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}{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 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