phase1d: demo catalog seeder (35 products, 8 groups, 2 suppliers) + product edit form

Starter experience so the system is usable immediately after git clone → migrate → run.

DemoCatalogSeeder (Development only, runs once — skips if tenant has any products):
- 8 product groups: Напитки (Безалкогольные, Алкогольные), Молочка, Хлеб, Кондитерские,
  Бакалея, Снеки — hierarchical Path computed
- 2 demo suppliers: ТОО «Продтрейд» (legal entity, KZ BIN, bank details), ИП Иванов (individual)
- 35 realistic KZ-market products with:
  - Demo barcodes in 2xxx internal range (won't collide with real products)
  - Retail price + purchase price at 72% of retail
  - Country of origin (KZ / RU)
  - Хлеб marked as 0% VAT (socially important goods in KZ)
  - Сыр «Российский» marked as весовой
  - Articles in kebab-case: DR-SOD-001, DAI-MLK-002, SW-CHO-001 etc.

Product form (full page /catalog/products/new and /:id, not modal):
- 5 sections: Основное / Классификация / Остатки и закупка / Цены / Штрихкоды
- Dropdowns for unit, VAT, group, country, supplier, currency via useLookups hooks
- Defaults pre-filled for new product (default VAT, base unit, KZT)
- Prices table: add/remove rows, pick price type + amount + currency
- Barcodes table: EAN-13/8/CODE128/UPC options, "primary" enforces single
- Server-side atomic save (existing Prices+Barcodes replaced on PUT)

Products page: row click → edit page, Add button → new page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-21 20:38:23 +05:00
parent 26d529b09b
commit 1b2b5393fa
6 changed files with 609 additions and 8 deletions

View file

@ -97,6 +97,7 @@
builder.Services.AddHostedService<OpenIddictClientSeeder>(); builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>(); builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>(); builder.Services.AddHostedService<DevDataSeeder>();
builder.Services.AddHostedService<DemoCatalogSeeder>();
var app = builder.Build(); var app = builder.Build();

View file

@ -0,0 +1,200 @@
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Seed;
// Populates a starter catalog so the system is usable immediately after first run.
// Runs only in Development and only if the tenant has no products yet — safe to run multiple times.
public class DemoCatalogSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _env;
public DemoCatalogSeeder(IServiceProvider services, IHostEnvironment env)
{
_services = services;
_env = env;
}
public async Task StartAsync(CancellationToken ct)
{
if (!_env.IsDevelopment()) return;
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var demoOrg = await db.Organizations.FirstOrDefaultAsync(o => o.Name == "Demo Market", ct);
if (demoOrg is null) return;
var orgId = demoOrg.Id;
// Skip if products already present — don't re-seed on every restart.
var hasProducts = await db.Products.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (hasProducts) return;
var defaultVat = await db.VatRates.IgnoreQueryFilters()
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.IsDefault, ct);
var noVat = await db.VatRates.IgnoreQueryFilters()
.FirstOrDefaultAsync(v => v.OrganizationId == orgId && v.Percent == 0m, ct);
var unitSht = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "шт", ct);
var unitKg = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "кг", ct);
var unitL = await db.UnitsOfMeasure.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.OrganizationId == orgId && u.Symbol == "л", ct);
if (defaultVat is null || unitSht is null) return;
var vat = defaultVat.Id;
var vat0 = noVat?.Id ?? vat;
var retailPriceType = await db.PriceTypes.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
if (retailPriceType is null) return;
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
if (kzt is null) return;
var kz = await db.Countries.FirstOrDefaultAsync(c => c.Code == "KZ", ct);
var ru = await db.Countries.FirstOrDefaultAsync(c => c.Code == "RU", ct);
// Product groups (hierarchy — Напитки/Безалкогольные, Напитки/Алкогольные, etc.)
var groups = new Dictionary<string, Guid>();
Guid AddGroup(string name, Guid? parentId)
{
var id = Guid.NewGuid();
var path = parentId is null
? name
: $"{groups.First(g => g.Value == parentId).Key}/{name}";
db.ProductGroups.Add(new ProductGroup
{
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
Path = path, SortOrder = groups.Count, IsActive = true,
});
groups[path] = id;
return id;
}
var gDrinks = AddGroup("Напитки", null);
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
AddGroup("Алкогольные", gDrinks);
var gDairy = AddGroup("Молочные продукты", null);
var gBakery = AddGroup("Хлеб и выпечка", null);
var gSweets = AddGroup("Кондитерские", null);
var gGrocery = AddGroup("Бакалея", null);
var gSnacks = AddGroup("Снеки", null);
// Demo suppliers
var supplier1 = new Counterparty
{
OrganizationId = orgId, Name = "ТОО «Продтрейд»", LegalName = "Товарищество с ограниченной ответственностью «Продтрейд»",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.LegalEntity,
Bin = "100140005678", CountryId = kz?.Id,
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
IsActive = true,
};
var supplier2 = new Counterparty
{
OrganizationId = orgId, Name = "ИП Иванов А.С.",
Kind = CounterpartyKind.Supplier, Type = CounterpartyType.Individual,
Iin = "850101300000", CountryId = kz?.Id,
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
IsActive = true,
};
db.Counterparties.AddRange(supplier1, supplier2);
// Barcodes use generic internal range (2xxxxxxxxxxxx) to avoid colliding with real products.
// When user does real приёмка, real barcodes will overwrite.
var demo = new (string Name, Guid Group, decimal RetailPrice, string Article, string Barcode, Guid? Country, bool IsWeighed, Guid Unit, bool IsAlcohol)[]
{
// Напитки — безалкогольные
("Вода питьевая «Тассай» 0.5л", gDrinksNon, 220, "DR-WAT-001", "2000000000018", kz?.Id, false, unitSht.Id, false),
("Вода питьевая «Тассай» 1.5л", gDrinksNon, 380, "DR-WAT-002", "2000000000025", kz?.Id, false, unitSht.Id, false),
("Сок «Да-Да» яблоко 1л", gDrinksNon, 650, "DR-JUC-001", "2000000000032", kz?.Id, false, unitSht.Id, false),
("Сок «Да-Да» апельсин 1л", gDrinksNon, 650, "DR-JUC-002", "2000000000049", kz?.Id, false, unitSht.Id, false),
("Кока-кола 0.5л", gDrinksNon, 420, "DR-SOD-001", "2000000000056", ru?.Id, false, unitSht.Id, false),
("Пепси 0.5л", gDrinksNon, 420, "DR-SOD-002", "2000000000063", ru?.Id, false, unitSht.Id, false),
("Спрайт 0.5л", gDrinksNon, 420, "DR-SOD-003", "2000000000070", ru?.Id, false, unitSht.Id, false),
("Чай чёрный «Пиала» пакетированный, 100шт", gDrinksNon, 1250, "DR-TEA-001", "2000000000087", kz?.Id, false, unitSht.Id, false),
// Молочные
("Молоко «Простоквашино» 2.5% 930мл", gDairy, 720, "DAI-MLK-001", "2000000000094", ru?.Id, false, unitSht.Id, false),
("Молоко «Food Master» 3.2% 1л", gDairy, 620, "DAI-MLK-002", "2000000000100", kz?.Id, false, unitSht.Id, false),
("Кефир «Food Master» 2.5% 1л", gDairy, 590, "DAI-KEF-001", "2000000000117", kz?.Id, false, unitSht.Id, false),
("Йогурт «Актимель» клубника 100мл", gDairy, 180, "DAI-YOG-001", "2000000000124", null, false, unitSht.Id, false),
("Творог «Простоквашино» 5% 200г", gDairy, 590, "DAI-CRD-001", "2000000000131", ru?.Id, false, unitSht.Id, false),
("Сметана «Food Master» 20% 400г", gDairy, 850, "DAI-SCR-001", "2000000000148", kz?.Id, false, unitSht.Id, false),
("Сыр «Российский» 45%", gDairy, 3200, "DAI-CHE-001", "2000000000155", kz?.Id, true, unitKg?.Id ?? unitSht.Id, false),
// Хлеб и выпечка
("Хлеб «Семейный» нарезной 500г", gBakery, 180, "BAK-BRD-001", "2000000000162", kz?.Id, false, unitSht.Id, false),
("Батон «Столичный» 400г", gBakery, 170, "BAK-BRD-002", "2000000000179", kz?.Id, false, unitSht.Id, false),
("Лепёшка домашняя", gBakery, 220, "BAK-LEP-001", "2000000000186", kz?.Id, false, unitSht.Id, false),
("Круассан шоколадный", gBakery, 320, "BAK-CRO-001", "2000000000193", kz?.Id, false, unitSht.Id, false),
// Кондитерские
("Шоколад «Рахат» молочный 100г", gSweets, 650, "SW-CHO-001", "2000000000209", kz?.Id, false, unitSht.Id, false),
("Шоколад «Алёнка» 100г", gSweets, 620, "SW-CHO-002", "2000000000216", ru?.Id, false, unitSht.Id, false),
("Конфеты «Казахстанские» 200г", gSweets, 890, "SW-CND-001", "2000000000223", kz?.Id, false, unitSht.Id, false),
("Печенье «Юбилейное» 250г", gSweets, 540, "SW-CKE-001", "2000000000230", ru?.Id, false, unitSht.Id, false),
("Вафли «Артек» 250г", gSweets, 480, "SW-WAF-001", "2000000000247", kz?.Id, false, unitSht.Id, false),
// Бакалея
("Сахар-песок 1кг", gGrocery, 480, "GRO-SUG-001", "2000000000254", kz?.Id, false, unitSht.Id, false),
("Соль поваренная 1кг", gGrocery, 180, "GRO-SLT-001", "2000000000261", kz?.Id, false, unitSht.Id, false),
("Рис круглозёрный 1кг", gGrocery, 850, "GRO-RCE-001", "2000000000278", kz?.Id, false, unitSht.Id, false),
("Гречка «Мистраль» 900г", gGrocery, 990, "GRO-GRE-001", "2000000000285", ru?.Id, false, unitSht.Id, false),
("Макароны «Corona» 500г", gGrocery, 420, "GRO-PAS-001", "2000000000292", kz?.Id, false, unitSht.Id, false),
("Масло подсолнечное «Шедевр» 1л", gGrocery, 920, "GRO-OIL-001", "2000000000308", kz?.Id, false, unitL?.Id ?? unitSht.Id, false),
("Кофе «Якобс Монарх» 95г растворимый", gGrocery, 2890, "GRO-COF-001", "2000000000315", null, false, unitSht.Id, false),
// Снеки
("Чипсы «Lays» сметана-лук 80г", gSnacks, 380, "SN-CHP-001", "2000000000322", kz?.Id, false, unitSht.Id, false),
("Сухарики «3 корочки» ржаные 40г", gSnacks, 190, "SN-SBR-001", "2000000000339", ru?.Id, false, unitSht.Id, false),
("Орешки фисташки 100г", gSnacks, 1190, "SN-NUT-001", "2000000000346", kz?.Id, false, unitSht.Id, false),
("Семечки «Мартин» 100г", gSnacks, 290, "SN-SED-001", "2000000000353", ru?.Id, false, unitSht.Id, false),
};
var products = demo.Select(d =>
{
var p = new Product
{
OrganizationId = orgId,
Name = d.Name,
Article = d.Article,
UnitOfMeasureId = d.Unit,
VatRateId = d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")
? vat0 : vat,
ProductGroupId = d.Group,
CountryOfOriginId = d.Country,
IsWeighed = d.IsWeighed,
IsAlcohol = d.IsAlcohol,
IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id,
Prices =
[
new ProductPrice
{
OrganizationId = orgId,
PriceTypeId = retailPriceType.Id,
Amount = d.RetailPrice,
CurrencyId = kzt.Id,
},
],
Barcodes =
[
new ProductBarcode
{
OrganizationId = orgId,
Code = d.Barcode,
Type = BarcodeType.Ean13,
IsPrimary = true,
},
],
};
return p;
}).ToList();
db.Products.AddRange(products);
await db.SaveChangesAsync(ct);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

View file

@ -12,6 +12,7 @@ import { RetailPointsPage } from '@/pages/RetailPointsPage'
import { ProductGroupsPage } from '@/pages/ProductGroupsPage' import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
import { CounterpartiesPage } from '@/pages/CounterpartiesPage' import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
import { ProductsPage } from '@/pages/ProductsPage' import { ProductsPage } from '@/pages/ProductsPage'
import { ProductEditPage } from '@/pages/ProductEditPage'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
@ -34,6 +35,8 @@ export default function App() {
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/catalog/products" element={<ProductsPage />} /> <Route path="/catalog/products" element={<ProductsPage />} />
<Route path="/catalog/products/new" element={<ProductEditPage />} />
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} /> <Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} /> <Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/vat-rates" element={<VatRatesPage />} /> <Route path="/catalog/vat-rates" element={<VatRatesPage />} />

View file

@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type {
PagedResult, UnitOfMeasure, VatRate, ProductGroup, Counterparty,
Country, Currency, Store, PriceType,
} from '@/lib/types'
function useLookup<T>(key: string, url: string) {
return useQuery({
queryKey: [`lookup:${key}`],
queryFn: async () => (await api.get<PagedResult<T>>(`${url}?pageSize=500`)).data.items,
staleTime: 5 * 60 * 1000,
})
}
export const useUnits = () => useLookup<UnitOfMeasure>('units', '/api/catalog/units-of-measure')
export const useVatRates = () => useLookup<VatRate>('vat', '/api/catalog/vat-rates')
export const useProductGroups = () => useLookup<ProductGroup>('groups', '/api/catalog/product-groups')
export const useCountries = () => useLookup<Country>('countries', '/api/catalog/countries')
export const useCurrencies = () => useLookup<Currency>('currencies', '/api/catalog/currencies')
export const useStores = () => useLookup<Store>('stores', '/api/catalog/stores')
export const usePriceTypes = () => useLookup<PriceType>('price-types', '/api/catalog/price-types')
export const useSuppliers = () => useQuery({
queryKey: ['lookup:suppliers'],
queryFn: async () => (await api.get<PagedResult<Counterparty>>('/api/catalog/counterparties?pageSize=500&kind=1')).data.items,
staleTime: 5 * 60 * 1000,
})

View file

@ -0,0 +1,370 @@
import { useState, useEffect, type FormEvent } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import {
useUnits, useVatRates, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups'
import { BarcodeType, type Product } from '@/lib/types'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
interface Form {
name: string
article: string
description: string
unitOfMeasureId: string
vatRateId: string
productGroupId: string
defaultSupplierId: string
countryOfOriginId: string
isService: boolean
isWeighed: boolean
isAlcohol: boolean
isMarked: boolean
isActive: boolean
minStock: string
maxStock: string
purchasePrice: string
purchaseCurrencyId: string
imageUrl: string
prices: PriceRow[]
barcodes: BarcodeRow[]
}
const emptyForm: Form = {
name: '', article: '', description: '',
unitOfMeasureId: '', vatRateId: '',
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, isWeighed: false, isAlcohol: false, isMarked: false, isActive: true,
minStock: '', maxStock: '',
purchasePrice: '', purchaseCurrencyId: '',
imageUrl: '',
prices: [],
barcodes: [],
}
export function ProductEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const units = useUnits()
const vats = useVatRates()
const groups = useProductGroups()
const countries = useCountries()
const currencies = useCurrencies()
const priceTypes = usePriceTypes()
const suppliers = useSuppliers()
const existing = useQuery({
queryKey: ['/api/catalog/products', id],
queryFn: async () => (await api.get<Product>(`/api/catalog/products/${id}`)).data,
enabled: !isNew,
})
const [form, setForm] = useState<Form>(emptyForm)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!isNew && existing.data) {
const p = existing.data
setForm({
name: p.name, article: p.article ?? '', description: p.description ?? '',
unitOfMeasureId: p.unitOfMeasureId, vatRateId: p.vatRateId,
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
countryOfOriginId: p.countryOfOriginId ?? '',
isService: p.isService, isWeighed: p.isWeighed, isAlcohol: p.isAlcohol, isMarked: p.isMarked,
isActive: p.isActive,
minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '',
purchasePrice: p.purchasePrice?.toString() ?? '',
purchaseCurrencyId: p.purchaseCurrencyId ?? '',
imageUrl: p.imageUrl ?? '',
prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })),
barcodes: p.barcodes.map((x) => ({ id: x.id, code: x.code, type: x.type, isPrimary: x.isPrimary })),
})
}
}, [isNew, existing.data])
useEffect(() => {
// Pre-fill defaults for new product
if (isNew && form.vatRateId === '' && vats.data?.length) {
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
}
if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.find(u => u.isBase)?.id ?? units.data?.[0]?.id ?? '' }))
}
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
setForm((f) => ({ ...f, purchaseCurrencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' }))
}
}, [isNew, vats.data, units.data, currencies.data, form.vatRateId, form.unitOfMeasureId, form.purchaseCurrencyId])
const save = useMutation({
mutationFn: async () => {
const payload = {
name: form.name,
article: form.article || null,
description: form.description || null,
unitOfMeasureId: form.unitOfMeasureId,
vatRateId: form.vatRateId,
productGroupId: form.productGroupId || null,
defaultSupplierId: form.defaultSupplierId || null,
countryOfOriginId: form.countryOfOriginId || null,
isService: form.isService,
isWeighed: form.isWeighed,
isAlcohol: form.isAlcohol,
isMarked: form.isMarked,
isActive: form.isActive,
minStock: form.minStock === '' ? null : Number(form.minStock),
maxStock: form.maxStock === '' ? null : Number(form.maxStock),
purchasePrice: form.purchasePrice === '' ? null : Number(form.purchasePrice),
purchaseCurrencyId: form.purchaseCurrencyId || null,
imageUrl: form.imageUrl || null,
prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })),
barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })),
}
if (isNew) {
return (await api.post<Product>('/api/catalog/products', payload)).data
}
await api.put(`/api/catalog/products/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })
navigate(created ? `/catalog/products/${created.id}` : '/catalog/products')
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/catalog/products/${id}`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })
navigate('/catalog/products')
},
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
const addPrice = () => setForm({ ...form, prices: [...form.prices, {
priceTypeId: priceTypes.data?.find(p => !form.prices.some(x => x.priceTypeId === p.id))?.id ?? priceTypes.data?.[0]?.id ?? '',
amount: 0,
currencyId: currencies.data?.find(c => c.code === 'KZT')?.id ?? '',
}] })
const removePrice = (i: number) => setForm({ ...form, prices: form.prices.filter((_, ix) => ix !== i) })
const updatePrice = (i: number, patch: Partial<PriceRow>) =>
setForm({ ...form, prices: form.prices.map((p, ix) => ix === i ? { ...p, ...patch } : p) })
const addBarcode = () => setForm({ ...form, barcodes: [...form.barcodes, {
code: '', type: BarcodeType.Ean13, isPrimary: form.barcodes.length === 0,
}] })
const removeBarcode = (i: number) =>
setForm({ ...form, barcodes: form.barcodes.filter((_, ix) => ix !== i) })
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
return (
<form onSubmit={onSubmit} className="p-6 max-w-5xl">
<div className="flex items-center justify-between gap-4 mb-5">
<div className="flex items-center gap-3">
<Link to="/catalog/products" className="text-slate-400 hover:text-slate-600">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
{isNew ? 'Новый товар' : form.name || 'Товар'}
</h1>
<p className="text-sm text-slate-500">Справочник товаров и услуг</p>
</div>
</div>
<div className="flex gap-2">
{!isNew && (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => { if (confirm('Удалить товар?')) remove.mutate() }}
>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button type="submit" disabled={!form.name || !form.unitOfMeasureId || !form.vatRateId}>
<Save className="w-4 h-4" /> Сохранить
</Button>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<div className="space-y-5">
<Section title="Основное">
<div className="grid grid-cols-3 gap-3">
<Field label="Название *" className="col-span-2">
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Артикул">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
</div>
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</Section>
<Section title="Классификация">
<div className="grid grid-cols-3 gap-3">
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)}
</Select>
</Field>
<Field label="Ставка НДС *">
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
<option value=""></option>
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
</Select>
</Field>
<Field label="Группа">
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
<option value=""></option>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
</Field>
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Основной поставщик">
<Select value={form.defaultSupplierId} onChange={(e) => setForm({ ...form, defaultSupplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="URL изображения">
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-5 gap-3 pt-1">
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</Section>
<Section title="Остатки и закупка">
<div className="grid grid-cols-4 gap-3">
<Field label="Мин. остаток">
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
</Field>
<Field label="Макс. остаток">
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} />
</Field>
<Field label="Закупочная цена">
<TextInput type="number" step="0.01" value={form.purchasePrice} onChange={(e) => setForm({ ...form, purchasePrice: e.target.value })} />
</Field>
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
</div>
</Section>
<Section title="Цены продажи"
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.prices.length === 0 ? (
<div className="text-sm text-slate-400">Цен ещё нет. Добавь хотя бы розничную.</div>
) : (
<div className="space-y-2">
{form.prices.map((p, i) => (
<div key={i} className="grid grid-cols-[2fr_1fr_1fr_40px] gap-2 items-end">
<Field label={i === 0 ? 'Тип цены' : ''}>
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select>
</Field>
<Field label={i === 0 ? 'Сумма' : ''}>
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
</Field>
<Field label={i === 0 ? 'Валюта' : ''}>
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
<button type="button" onClick={() => removePrice(i)} className="text-slate-400 hover:text-red-600 pb-2">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</Section>
<Section title="Штрихкоды"
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.barcodes.length === 0 ? (
<div className="text-sm text-slate-400">Штрихкодов нет.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-[2fr_1fr_auto_40px] gap-2 items-end">
<Field label={i === 0 ? 'Код' : ''}>
<TextInput value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</Field>
<Field label={i === 0 ? 'Тип' : ''}>
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</Field>
<div className="pb-2">
<Checkbox label="Основной" checked={b.isPrimary} onChange={(v) => {
// Enforce single primary
setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })
}} />
</div>
<button type="button" onClick={() => removeBarcode(i)} className="text-slate-400 hover:text-red-600 pb-2">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</Section>
</div>
</form>
)
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action}
</div>
<div className="space-y-3">{children}</div>
</section>
)
}

View file

@ -1,3 +1,4 @@
import { Link, useNavigate } from 'react-router-dom'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
@ -10,6 +11,7 @@ import type { Product } from '@/lib/types'
const URL = '/api/catalog/products' const URL = '/api/catalog/products'
export function ProductsPage() { export function ProductsPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL) const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
return ( return (
@ -20,22 +22,20 @@ export function ProductsPage() {
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" /> <SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
<Button disabled title="Форма редактирования — в следующем коммите"> <Link to="/catalog/products/new">
<Plus className="w-4 h-4" /> Добавить <Button>
</Button> <Plus className="w-4 h-4" /> Добавить
</Button>
</Link>
</> </>
} }
/> />
<div className="mb-3 text-xs text-slate-400">
💡 Форма создания/редактирования товара (с ценами и штрихкодами) будет в следующем коммите.
Сейчас доступен просмотр и поиск создать товар можно через API (POST /api/catalog/products).
</div>
<DataTable <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
rowKey={(r) => r.id} rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
columns={[ columns={[
{ header: 'Название', cell: (r) => ( { header: 'Название', cell: (r) => (
<div> <div>