@ -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 < string , string | boolean > ;
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 < string , string > = {
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 < Tab > ( "products" ) ;
const [ products , setProducts ] = useState < CommerceProduct [ ] > ( [ ] ) ;
const [ sources , setSources ] = useState < SourceSystem [ ] > ( [ ] ) ;
const [ bindings , setBindings ] = useState < ProductBinding [ ] > ( [ ] ) ;
const [ sales , setSales ] = useState < CommerceSale [ ] > ( [ ] ) ;
const [ costs , setCosts ] = useState < CommerceCost [ ] > ( [ ] ) ;
const [ stock , setStock ] = useState < CommerceStockSnapshot [ ] > ( [ ] ) ;
const [ selectedSaleId , setSelectedSaleId ] = useState < string | null > ( null ) ;
const [ editor , setEditor ] = useState < Editor > ( null ) ;
const [ form , setForm ] = useState < FormState > ( { } ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ error , setError ] = useState < string | null > ( 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 < EditorKind , FormState > = {
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 < AppShell > < main className = "p-6 text-sm text-[var(--muted-foreground)]" > П р о в е р к а п р а в … < / main > < / AppShell > ;
}
if ( ! canManage ) {
return (
< AppShell >
< main className = "mx-auto max-w-3xl p-6" >
< div className = "app-feedback-danger rounded-xl p-5 text-sm" > У в а с н е т п р а в н а у п р а в л е н и е т о в а р н о й а н а л и т и к о й . < / div >
< / main >
< / AppShell >
) ;
}
return (
< AppShell desktopWideMode >
< main className = "mx-auto w-full max-w-[1600px] space-y-5 p-4 sm:p-6" >
< header className = "flex flex-wrap items-start justify-between gap-4" >
< div >
< Link href = "/products" className = "mb-2 inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]" > < ArrowLeft className = "h-4 w-4" / > К к а т а л о г у < / Link >
< h1 className = "text-2xl font-semibold" > У п р а в л е н и е т о в а р а м и и а н а л и т и к о й < / h1 >
< p className = "mt-1 text-sm text-[var(--muted-foreground)]" > Р у ч н о е у п р а в л е н и е с п р а в о ч н и к а м и и н о р м а л и з о в а н н ы м и ф а к т а м и . < / p >
< / div >
< button type = "button" onClick = { ( ) = > void loadData ( ) } className = "app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm" > < RefreshCw className = "h-4 w-4" / > О б н о в и т ь < / button >
< / header >
{ error ? < div className = "app-feedback-danger rounded-xl p-4 text-sm" > { error } < / div > : null }
< nav className = "app-surface flex gap-1 overflow-x-auto rounded-xl p-1.5" >
{ tabs . map ( ( item ) = > {
const Icon = item . icon ;
return < button key = { item . id } type = "button" onClick = { ( ) = > setTab ( item . id ) } className = { ` inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${ tab === item . id ? "app-selected" : "hover:bg-[var(--surface-secondary)]" } ` } > < Icon className = "h-4 w-4" / > { item . label } < / button > ;
} ) }
< / nav >
{ loading ? < div className = "app-surface rounded-xl p-6 text-sm text-[var(--muted-foreground)]" > З а г р у з к а … < / div > : (
< >
{ tab === "products" ? < ProductsTable items = { products } onCreate = { ( ) = > openCreate ( "product" ) } onEdit = { ( item ) = > openEdit ( "product" , item ) } onDelete = { ( id ) = > void remove ( "product" , id ) } / > : null }
{ tab === "sources" ? < SourcesTable items = { sources } onCreate = { ( ) = > openCreate ( "source" ) } onEdit = { ( item ) = > openEdit ( "source" , item ) } onDelete = { ( id ) = > void remove ( "source" , id ) } / > : null }
{ tab === "bindings" ? < BindingsTable items = { bindings } productMap = { productMap } sourceMap = { sourceMap } onCreate = { ( ) = > openCreate ( "binding" ) } onEdit = { ( item ) = > openEdit ( "binding" , item ) } onDelete = { ( id ) = > void remove ( "binding" , id ) } / > : null }
{ tab === "sales" ? < SalesTable items = { sales } sourceMap = { sourceMap } selectedSaleId = { selectedSaleId } onSelect = { setSelectedSaleId } onCreate = { ( ) = > openCreate ( "sale" ) } onEdit = { ( item ) = > openEdit ( "sale" , item ) } onDelete = { ( id ) = > void remove ( "sale" , id ) } / > : null }
{ tab === "sales" && selectedSale ? < SaleLinesTable sale = { selectedSale } productMap = { productMap } onCreate = { ( ) = > openCreate ( "line" ) } onEdit = { ( item ) = > openEdit ( "line" , item ) } onDelete = { ( id ) = > void remove ( "line" , id ) } / > : null }
{ tab === "costs" ? < CostsTable items = { costs } productMap = { productMap } sourceMap = { sourceMap } onCreate = { ( ) = > openCreate ( "cost" ) } onEdit = { ( item ) = > openEdit ( "cost" , item ) } onDelete = { ( id ) = > void remove ( "cost" , id ) } / > : null }
{ tab === "stock" ? < StockTable items = { stock } productMap = { productMap } sourceMap = { sourceMap } onCreate = { ( ) = > openCreate ( "stock" ) } onEdit = { ( item ) = > openEdit ( "stock" , item ) } onDelete = { ( id ) = > void remove ( "stock" , id ) } / > : null }
< / >
) }
< / main >
< Modal isOpen = { editor !== null } onClose = { ( ) = > setEditor ( null ) } title = { editorTitle ( editor ) } size = "lg" footer = { < div className = "flex justify-end gap-2 pb-4" > < button type = "button" onClick = { ( ) = > setEditor ( null ) } className = "app-action-secondary rounded-lg px-4 py-2 text-sm" > О т м е н а < / button > < button type = "submit" form = "commerce-editor-form" disabled = { saving } className = "app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50" > { saving ? "Сохранение…" : "Сохранить" } < / button > < / div > } >
{ editor ? < form id = "commerce-editor-form" onSubmit = { ( event ) = > { event . preventDefault ( ) ; void submitEditor ( ) ; } } > < EditorFields kind = { editor . kind } editorId = { editor . id } form = { form } setForm = { setForm } products = { products } sources = { sources } sales = { sales } / > < / form > : null }
< / Modal >
< / AppShell >
) ;
}
function editorTitle ( editor : Editor ) {
if ( ! editor ) return "" ;
const labels : Record < EditorKind , string > = { 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 < div className = "grid gap-4 pb-2 sm:grid-cols-2" >
{ kind === "product" ? < >
< Field label = "Внутренний SKU" required value = { String ( form . sku ) } onChange = { ( value ) = > set ( "sku" , value ) } / >
< Field label = "Название" required value = { String ( form . name ) } onChange = { ( value ) = > set ( "name" , value ) } / >
< SelectField label = "Тип" value = { String ( form . kind ) } onChange = { ( value ) = > set ( "kind" , value ) } options = { [ { value : "product" , label : "Товар" } , { value : "service" , label : "Услуга" } , { value : "bundle" , label : "Комплект" } ] } / >
< SelectField label = "Родительский товар" value = { String ( form . parent ) } onChange = { ( value ) = > set ( "parent" , value ) } options = { productOptions } emptyLabel = "Без родителя" / >
< Field label = "Штрихкод" value = { String ( form . barcode ) } onChange = { ( value ) = > set ( "barcode" , value ) } / >
< CheckboxField label = "Активен" checked = { Boolean ( form . is_active ) } onChange = { ( value ) = > set ( "is_active" , value ) } / >
< Field label = "Описание" value = { String ( form . description ) } onChange = { ( value ) = > set ( "description" , value ) } multiline className = "sm:col-span-2" / >
< / > : null }
{ kind === "source" ? < >
< Field label = "Код" required value = { String ( form . code ) } onChange = { ( value ) = > set ( "code" , value ) } placeholder = "moysklad" / >
< Field label = "Название" required value = { String ( form . name ) } onChange = { ( value ) = > set ( "name" , value ) } / >
< CheckboxField label = "Активен" checked = { Boolean ( form . is_active ) } onChange = { ( value ) = > set ( "is_active" , value ) } / >
< / > : null }
{ kind === "binding" ? < >
< SelectField label = "Товар" required value = { String ( form . product ) } onChange = { ( value ) = > set ( "product" , value ) } options = { productOptions } / >
< SelectField label = "Источник" required value = { String ( form . source ) } onChange = { ( value ) = > set ( "source" , value ) } options = { sourceOptions } / >
< Field label = "Внешний ID" required value = { String ( form . external_id ) } onChange = { ( value ) = > set ( "external_id" , value ) } / >
< Field label = "Внешний SKU" value = { String ( form . external_sku ) } onChange = { ( value ) = > set ( "external_sku" , value ) } / >
< CheckboxField label = "Основная привязка" checked = { Boolean ( form . is_primary ) } onChange = { ( value ) = > set ( "is_primary" , value ) } / >
< / > : null }
{ kind === "sale" ? < >
< SelectField label = "Источник" required value = { String ( form . source ) } onChange = { ( value ) = > set ( "source" , value ) } options = { sourceOptions } / >
< Field label = "Внешний ID" required value = { String ( form . external_id ) } onChange = { ( value ) = > set ( "external_id" , value ) } / >
< Field label = "Номер" value = { String ( form . number ) } onChange = { ( value ) = > set ( "number" , value ) } / >
< SelectField label = "Тип" value = { String ( form . event_type ) } onChange = { ( value ) = > set ( "event_type" , value ) } options = { [ { value : "sale" , label : "Продажа" } , { value : "return" , label : "Возврат" } ] } / >
< Field label = "Канал" value = { String ( form . channel ) } onChange = { ( value ) = > set ( "channel" , value ) } placeholder = "site, ozon, avito" / >
< Field label = "Статус" value = { String ( form . status ) } onChange = { ( value ) = > set ( "status" , value ) } / >
< Field label = "Валюта" required value = { String ( form . currency ) } onChange = { ( value ) = > set ( "currency" , value ) } / >
< Field label = "Дата операции" required type = "datetime-local" value = { String ( form . occurred_at ) } onChange = { ( value ) = > set ( "occurred_at" , value ) } / >
< CheckboxField label = "Учитывать в аналитике" checked = { Boolean ( form . counts_as_sale ) } onChange = { ( value ) = > set ( "counts_as_sale" , value ) } / >
< / > : null }
{ kind === "line" ? < >
< SelectField label = "Продажа" required value = { String ( form . sale ) } onChange = { ( value ) = > set ( "sale" , value ) } options = { sales . map ( ( sale ) = > ( { value : sale.id , label : sale.number || sale . external_id } ) ) } / >
< SelectField label = "Товар" required value = { String ( form . product ) } onChange = { ( value ) = > set ( "product" , value ) } options = { productOptions } / >
< Field label = "ID позиции" required value = { String ( form . external_id ) } onChange = { ( value ) = > set ( "external_id" , value ) } / >
< Field label = "Название в источнике" value = { String ( form . name ) } onChange = { ( value ) = > set ( "name" , value ) } / >
< Field label = "ID товара в источнике" value = { String ( form . external_product_id ) } onChange = { ( value ) = > set ( "external_product_id" , value ) } / >
< Field label = "Внешний SKU" value = { String ( form . external_sku ) } onChange = { ( value ) = > set ( "external_sku" , value ) } / >
< Field label = "Количество" required type = "number" step = "0.0001" value = { String ( form . quantity ) } onChange = { ( value ) = > set ( "quantity" , value ) } / >
< Field label = "Сумма до скидки" required type = "number" step = "0.01" value = { String ( form . gross_amount ) } onChange = { ( value ) = > set ( "gross_amount" , value ) } / >
< Field label = "Скидка" required type = "number" step = "0.01" value = { String ( form . discount_amount ) } onChange = { ( value ) = > set ( "discount_amount" , value ) } / >
< Field label = "Себестоимость" required type = "number" step = "0.01" value = { String ( form . cost_amount ) } onChange = { ( value ) = > set ( "cost_amount" , value ) } / >
< / > : null }
{ kind === "cost" ? < >
< SelectField label = "Источник" required value = { String ( form . source ) } onChange = { ( value ) = > set ( "source" , value ) } options = { sourceOptions } / >
< SelectField label = "Товар" required value = { String ( form . product ) } onChange = { ( value ) = > set ( "product" , value ) } options = { productOptions } / >
< SelectField label = "Тип расхода" required value = { String ( form . cost_type ) } onChange = { ( value ) = > set ( "cost_type" , value ) } options = { Object . entries ( costTypeLabels ) . map ( ( [ value , label ] ) = > ( { value , label } ) ) } / >
< Field label = "Канал" value = { String ( form . channel ) } onChange = { ( value ) = > set ( "channel" , value ) } / >
< Field label = "Сумма" required type = "number" step = "0.01" value = { String ( form . amount ) } onChange = { ( value ) = > set ( "amount" , value ) } / >
< Field label = "Валюта" required value = { String ( form . currency ) } onChange = { ( value ) = > set ( "currency" , value ) } / >
< Field label = "Дата расхода" required type = "datetime-local" value = { String ( form . occurred_at ) } onChange = { ( value ) = > set ( "occurred_at" , value ) } / >
< Field label = "Внешний ID" value = { String ( form . external_id ) } onChange = { ( value ) = > set ( "external_id" , value ) } / >
< / > : null }
{ kind === "stock" ? < >
< SelectField label = "Источник" required value = { String ( form . source ) } onChange = { ( value ) = > set ( "source" , value ) } options = { sourceOptions } / >
< SelectField label = "Товар" required value = { String ( form . product ) } onChange = { ( value ) = > set ( "product" , value ) } options = { productOptions } / >
< Field label = "ID склада" required value = { String ( form . warehouse_external_id ) } onChange = { ( value ) = > set ( "warehouse_external_id" , value ) } / >
< Field label = "Название склада" value = { String ( form . warehouse_name ) } onChange = { ( value ) = > set ( "warehouse_name" , value ) } / >
< Field label = "Количество" required type = "number" step = "0.0001" value = { String ( form . quantity ) } onChange = { ( value ) = > set ( "quantity" , value ) } / >
< Field label = "Резерв" required type = "number" step = "0.0001" value = { String ( form . reserved ) } onChange = { ( value ) = > set ( "reserved" , value ) } / >
< Field label = "В пути" required type = "number" step = "0.0001" value = { String ( form . in_transit ) } onChange = { ( value ) = > set ( "in_transit" , value ) } / >
< Field label = "Дата снимка" required type = "datetime-local" value = { String ( form . captured_at ) } onChange = { ( value ) = > set ( "captured_at" , value ) } / >
< / > : null }
< / div > ;
}
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 overflow-hidden rounded-xl" > < header className = "flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border-subtle)] p-4" > < div > < h2 className = "font-semibold" > { title } < / h2 > < p className = "mt-1 text-xs text-[var(--muted-foreground)]" > { 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 > ;
}
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 = "Изменить" > < Pencil className = "h-4 w-4" / > < / button > < button type = "button" onClick = { onDelete } className = "app-icon-button rounded-lg p-2 text-rose-600" title = "Удалить" > < 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 = "Единые внутренние SKU" onCreate = { onCreate } > < Table headers = { [ "SKU" , "Название" , "Тип" , "Привязки" , "" ] } > { items . length ? items . map ( ( item ) = > < tr key = { item . id } > < Td > { item . sku } < / Td > < Td > { item . name } < / Td > < Td > { item . kind } < / 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 Table ( { headers , children } : { headers : string [ ] ; children : ReactNode } ) { return < div className = "overflow-x-auto" > < table className = "w-full min-w-[760px] text-left text-sm" > < thead className = "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 = "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 > ; }
function SelectField ( { label , value , onChange , options , required = false , emptyLabel = "Выберите" } : { label : string ; value : string ; onChange : ( value : string ) = > void ; options : Array < { value : string ; label : string } > ; required? : boolean ; emptyLabel? : string } ) { return < label > < span className = "text-xs font-medium text-[var(--muted-foreground)]" > { label } { required ? " *" : "" } < / span > < select required = { required } value = { value } onChange = { ( event ) = > onChange ( event . target . value ) } className = "mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none" > < option value = "" > { emptyLabel } < / option > { options . map ( ( option ) = > < option key = { option . value } value = { option . value } > { option . label } < / option > ) } < / select > < / label > ; }
function CheckboxField ( { label , checked , onChange } : { label : string ; checked : boolean ; onChange : ( value : boolean ) = > void } ) { return < label className = "flex items-center gap-2 self-end rounded-lg border border-[var(--border-subtle)] px-3 py-2 text-sm" > < input type = "checkbox" checked = { checked } onChange = { ( event ) = > onChange ( event . target . checked ) } / > { label } < / label > ; }