Compare commits
No commits in common. "bed30f68bd0c1e9c033a172e0d608c2538c73e24" and "3ed6fe25be3d4a604071b7e0f2c02bb55c82c1be" have entirely different histories.
bed30f68bd
...
3ed6fe25be
|
|
@ -33,12 +33,14 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
|
|||
("name", true) => q.OrderByDescending(c => c.Name),
|
||||
("symbol", false) => q.OrderBy(c => c.Symbol),
|
||||
("symbol", true) => q.OrderByDescending(c => c.Symbol),
|
||||
("isActive", false) => q.OrderBy(c => c.IsActive).ThenBy(c => c.Code),
|
||||
("isActive", true) => q.OrderByDescending(c => c.IsActive).ThenBy(c => c.Code),
|
||||
("code", true) => q.OrderByDescending(c => c.Code),
|
||||
_ => q.OrderBy(c => c.Code),
|
||||
};
|
||||
var items = await q
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol))
|
||||
.Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<CurrencyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -47,7 +49,7 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> List([FromQuery] Paged
|
|||
public async Task<ActionResult<CurrencyDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var c = await _db.Currencies.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol);
|
||||
return c is null ? NotFound() : new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "SuperAdmin")]
|
||||
|
|
@ -58,11 +60,13 @@ public async Task<ActionResult<CurrencyDto>> Create([FromBody] CurrencyInput inp
|
|||
Code = input.Code.Trim().ToUpper(),
|
||||
Name = input.Name,
|
||||
Symbol = input.Symbol,
|
||||
MinorUnit = input.MinorUnit,
|
||||
IsActive = input.IsActive,
|
||||
};
|
||||
_db.Currencies.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol));
|
||||
new CurrencyDto(e.Id, e.Code, e.Name, e.Symbol, e.MinorUnit, e.IsActive));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "SuperAdmin")]
|
||||
|
|
@ -73,6 +77,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CurrencyInput input,
|
|||
e.Code = input.Code.Trim().ToUpper();
|
||||
e.Name = input.Name;
|
||||
e.Symbol = input.Symbol;
|
||||
e.MinorUnit = input.MinorUnit;
|
||||
e.IsActive = input.IsActive;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,23 +23,6 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
|
|||
_tenant = tenant;
|
||||
}
|
||||
|
||||
// Следующий числовой артикул для организации. Находит max(Article::int)
|
||||
// среди артикулов, которые полностью состоят из цифр, и прибавляет 1.
|
||||
// Если числовых артикулов нет — возвращает "1".
|
||||
private async Task<string> GenerateNextArticleAsync(CancellationToken ct)
|
||||
{
|
||||
var articles = await _db.Products
|
||||
.Where(p => p.Article != null && p.Article != "")
|
||||
.Select(p => p.Article!)
|
||||
.ToListAsync(ct);
|
||||
var next = 1;
|
||||
foreach (var a in articles)
|
||||
{
|
||||
if (int.TryParse(a, out var n) && n >= next) next = n + 1;
|
||||
}
|
||||
return next.ToString();
|
||||
}
|
||||
|
||||
// Дефолт Vat для нового товара — из страны организации (Country.VatRate).
|
||||
private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||
{
|
||||
|
|
@ -109,10 +92,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
("group", true) => q.OrderByDescending(p => p.ProductGroup != null ? p.ProductGroup.Name : null).ThenBy(p => p.Name),
|
||||
("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
||||
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
||||
("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
|
||||
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
|
||||
("purchasePrice", false) => q.OrderBy(p => p.PurchasePrice).ThenBy(p => p.Name),
|
||||
("purchasePrice", true) => q.OrderByDescending(p => p.PurchasePrice).ThenBy(p => p.Name),
|
||||
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
|
||||
("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
|
||||
("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name),
|
||||
|
|
@ -141,8 +120,6 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
|||
Apply(e, input);
|
||||
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
||||
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
||||
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
||||
if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);
|
||||
|
||||
foreach (var b in input.Barcodes ?? [])
|
||||
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
|
||||
|
|
@ -150,14 +127,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
|||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
|
||||
|
||||
_db.Products.Add(e);
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
|
||||
{
|
||||
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
var dto = await GetInternalAsync(e.Id, ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
|
||||
}
|
||||
|
|
@ -186,14 +156,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
|||
foreach (var pr in input.Prices ?? [])
|
||||
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = pr.Amount, CurrencyId = pr.CurrencyId });
|
||||
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_products_OrganizationId_Article") == true)
|
||||
{
|
||||
return BadRequest(new { error = $"Артикул «{e.Article}» уже занят в этой организации." });
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@ public record OrgSettingsDto(
|
|||
decimal VatRate,
|
||||
bool ShowVatEnabledOnProduct,
|
||||
bool ShowServiceOnProduct,
|
||||
bool ShowMarkedOnProduct,
|
||||
bool ShowMinMaxStock);
|
||||
bool ShowMarkedOnProduct);
|
||||
|
||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||
public record OrgSettingsInput(
|
||||
|
|
@ -42,8 +41,7 @@ public record OrgSettingsInput(
|
|||
bool MultiCurrencyEnabled,
|
||||
bool ShowVatEnabledOnProduct,
|
||||
bool ShowServiceOnProduct,
|
||||
bool ShowMarkedOnProduct,
|
||||
bool ShowMinMaxStock);
|
||||
bool ShowMarkedOnProduct);
|
||||
|
||||
[HttpGet("settings")]
|
||||
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
||||
|
|
@ -77,7 +75,6 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
|
|||
o.ShowVatEnabledOnProduct = input.ShowVatEnabledOnProduct;
|
||||
o.ShowServiceOnProduct = input.ShowServiceOnProduct;
|
||||
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
||||
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
|
||||
|
|
@ -103,6 +100,5 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
|||
vat,
|
||||
o.ShowVatEnabledOnProduct,
|
||||
o.ShowServiceOnProduct,
|
||||
o.ShowMarkedOnProduct,
|
||||
o.ShowMinMaxStock);
|
||||
o.ShowMarkedOnProduct);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ public record CountryDto(
|
|||
decimal VatRate);
|
||||
|
||||
|
||||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
||||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol, int MinorUnit, bool IsActive);
|
||||
|
||||
public record UnitOfMeasureDto(
|
||||
Guid Id, string Code, string Name, string? Description, bool IsActive);
|
||||
|
|
@ -55,7 +55,7 @@ public record ProductDto(
|
|||
public record CountryInput(
|
||||
string Code, string Name,
|
||||
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
|
||||
public record CurrencyInput(string Code, string Name, string Symbol);
|
||||
public record CurrencyInput(string Code, string Name, string Symbol, int MinorUnit = 2, bool IsActive = true);
|
||||
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
|
||||
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
|
||||
public record StoreInput(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ public class Currency : Entity
|
|||
public string Code { get; set; } = null!; // ISO 4217, e.g. "KZT"
|
||||
public string Name { get; set; } = null!;
|
||||
public string Symbol { get; set; } = null!; // "₸"
|
||||
// Количество знаков после запятой для форматирования цен. Не редактируется
|
||||
// в UI — задаётся сидером/миграцией по ISO 4217.
|
||||
public int MinorUnit { get; set; } = 2;
|
||||
public int MinorUnit { get; set; } = 2; // 2 = two decimal places
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,4 @@ public class Organization : Entity
|
|||
/// Маркировка требуется только в нишевых категориях (алкоголь, лекарства,
|
||||
/// табак) — по умолчанию выключено.</summary>
|
||||
public bool ShowMarkedOnProduct { get; set; }
|
||||
|
||||
/// <summary>Показывать ли поля «Минимальный остаток» / «Максимальный остаток»
|
||||
/// на карточке товара и одноимённую колонку в списке. Нужно в основном
|
||||
/// торговым сетям со свободным местом на полке — по умолчанию выключено.</summary>
|
||||
public bool ShowMinMaxStock { get; set; }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Удаляет currencies.IsActive. Валюты — глобальный справочник,
|
||||
/// «архивировать» USD не имеет смысла. Если какая-то валюта не нужна
|
||||
/// конкретному магазину — пользователь её просто не выбирает.</summary>
|
||||
public partial class Phase5e_DropCurrencyIsActive : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b)
|
||||
{
|
||||
b.DropColumn(name: "IsActive", schema: "public", table: "currencies");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder b)
|
||||
{
|
||||
b.AddColumn<bool>(
|
||||
name: "IsActive", schema: "public", table: "currencies",
|
||||
type: "boolean", nullable: false, defaultValue: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Добавляет organizations.ShowMinMaxStock — флаг видимости
|
||||
/// полей мин/макс остатка на карточке товара и одноимённой колонки
|
||||
/// в списке. По умолчанию false.</summary>
|
||||
public partial class Phase5f_ShowMinMaxStock : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b)
|
||||
{
|
||||
b.AddColumn<bool>(
|
||||
name: "ShowMinMaxStock", schema: "public", table: "organizations",
|
||||
type: "boolean", nullable: false, defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder b)
|
||||
{
|
||||
b.DropColumn(name: "ShowMinMaxStock", schema: "public", table: "organizations");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -473,6 +473,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MinorUnit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
|
@ -1093,9 +1096,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<bool>("ShowMarkedOnProduct")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("ShowMinMaxStock")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("ShowServiceOnProduct")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
|
|
|||
|
|
@ -18,15 +18,14 @@ export function Field({ label, error, children, className }: FieldProps) {
|
|||
)
|
||||
}
|
||||
|
||||
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60'
|
||||
const inputClass = 'w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60'
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={cn(inputClass, props.className)} />
|
||||
}
|
||||
|
||||
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
// TextArea — multi-line, высоту не фиксируем.
|
||||
return <textarea {...props} className={cn(inputClass, 'h-auto py-2 font-[inherit] leading-normal', props.className)} />
|
||||
return <textarea {...props} className={cn(inputClass, 'font-[inherit]', props.className)} />
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
pageSize: number
|
||||
|
|
@ -11,20 +9,8 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
|
|||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const from = (page - 1) * pageSize + 1
|
||||
const to = Math.min(page * pageSize, total)
|
||||
const [jumpValue, setJumpValue] = useState<string>(String(page))
|
||||
|
||||
useEffect(() => { setJumpValue(String(page)) }, [page])
|
||||
|
||||
if (total === 0) return null
|
||||
|
||||
const commitJump = () => {
|
||||
const n = parseInt(jumpValue, 10)
|
||||
if (!Number.isFinite(n)) { setJumpValue(String(page)); return }
|
||||
const clamped = Math.min(Math.max(1, n), totalPages)
|
||||
if (clamped !== page) onPageChange(clamped)
|
||||
setJumpValue(String(clamped))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-3 text-sm text-slate-500">
|
||||
<span>{from}–{to} из {total}</span>
|
||||
|
|
@ -36,20 +22,7 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
|
|||
>
|
||||
←
|
||||
</button>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Страница</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpValue}
|
||||
onChange={(e) => setJumpValue(e.target.value)}
|
||||
onBlur={commitJump}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitJump() } }}
|
||||
className="w-14 px-1.5 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-center tabular-nums"
|
||||
/>
|
||||
<span>из {totalPages}</span>
|
||||
</span>
|
||||
<span>{page} / {totalPages}</span>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,21 @@
|
|||
// Штрихкод-утилиты: генерация валидных кодов под разные форматы.
|
||||
// EAN-13 утилиты.
|
||||
//
|
||||
// Внутренние EAN-13 магазина начинаются с "2" — зарезервированный префикс
|
||||
// для in-store use, не пересекается с GTIN реальных товаров.
|
||||
// Внутренние штрихкоды магазина начинаются с "2" — это зарезервированный
|
||||
// префикс для in-store use, он не пересекается с GTIN реальных товаров
|
||||
// от производителей.
|
||||
|
||||
import { BarcodeType } from '@/lib/types'
|
||||
|
||||
function digitsChecksum(first: string, weightAtOdd: number): number {
|
||||
// Общая EAN/UPC-подобная формула: сумма с чередующимися весами, остаток от 10.
|
||||
// Нечётные позиции (с индекса 0) — weightAtOdd, чётные — 1.
|
||||
function ean13Checksum(first12: string): number {
|
||||
let sum = 0
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
const d = first.charCodeAt(i) - 48
|
||||
sum += i % 2 === 0 ? d * weightAtOdd : d
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const d = first12.charCodeAt(i) - 48
|
||||
sum += i % 2 === 0 ? d : d * 3
|
||||
}
|
||||
return (10 - (sum % 10)) % 10
|
||||
}
|
||||
|
||||
function randomDigits(n: number): string {
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) s += Math.floor(Math.random() * 10).toString()
|
||||
return s
|
||||
}
|
||||
|
||||
function randomAlnum(n: number): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||
return s
|
||||
}
|
||||
|
||||
function ean13(): string {
|
||||
const body = '2' + randomDigits(11)
|
||||
return body + digitsChecksum(body, 3).toString()
|
||||
}
|
||||
|
||||
function ean8(): string {
|
||||
// EAN-8: 7 цифр + checksum. Веса: нечётные×3, чётные×1 (с индекса 0).
|
||||
const body = randomDigits(7)
|
||||
return body + digitsChecksum(body, 3).toString()
|
||||
}
|
||||
|
||||
function upca(): string {
|
||||
// UPC-A: 11 цифр + checksum. Та же формула что у EAN-13.
|
||||
const body = randomDigits(11)
|
||||
return body + digitsChecksum(body, 3).toString()
|
||||
}
|
||||
|
||||
function upce(): string {
|
||||
// Упрощённая генерация: 8 случайных цифр (реальный UPC-E строится через
|
||||
// сжатие UPC-A по спецправилам; для внутренних нужд достаточно числовой
|
||||
// последовательности нужной длины).
|
||||
return randomDigits(8)
|
||||
}
|
||||
|
||||
/** Сгенерировать внутренний EAN-13 с префиксом "2" и случайной серединой. */
|
||||
export function generateEan13InternalPrefix2(): string {
|
||||
return ean13()
|
||||
}
|
||||
|
||||
/** Сгенерировать штрихкод под указанный формат. */
|
||||
export function generateBarcode(type: BarcodeType): string {
|
||||
switch (type) {
|
||||
case BarcodeType.Ean13: return ean13()
|
||||
case BarcodeType.Ean8: return ean8()
|
||||
case BarcodeType.Upca: return upca()
|
||||
case BarcodeType.Upce: return upce()
|
||||
case BarcodeType.Code128:
|
||||
case BarcodeType.Code39: return randomAlnum(12)
|
||||
case BarcodeType.Other:
|
||||
default: return ean13()
|
||||
}
|
||||
let body = '2'
|
||||
for (let i = 0; i < 11; i++) body += Math.floor(Math.random() * 10).toString()
|
||||
return body + ean13Checksum(body).toString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export interface Country {
|
|||
defaultCurrencySymbol: string | null
|
||||
vatRate: number
|
||||
}
|
||||
export interface Currency { id: string; code: string; name: string; symbol: string }
|
||||
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
|
||||
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
|
||||
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
|
||||
export interface Store {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export interface OrgSettings {
|
|||
showVatEnabledOnProduct: boolean
|
||||
showServiceOnProduct: boolean
|
||||
showMarkedOnProduct: boolean
|
||||
showMinMaxStock: boolean
|
||||
}
|
||||
|
||||
export function useOrgSettings() {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export function CurrenciesPage() {
|
|||
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ header: 'Символ', width: '100px', sortKey: 'symbol', cell: (r) => <span className="text-lg">{r.symbol}</span> },
|
||||
{ header: 'Знаки', width: '100px', className: 'text-right', cell: (r) => r.minorUnit },
|
||||
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ export function OrganizationSettingsPage() {
|
|||
showVatEnabledOnProduct: form.showVatEnabledOnProduct,
|
||||
showServiceOnProduct: form.showServiceOnProduct,
|
||||
showMarkedOnProduct: form.showMarkedOnProduct,
|
||||
showMinMaxStock: form.showMinMaxStock,
|
||||
}
|
||||
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
|
||||
},
|
||||
|
|
@ -126,16 +125,6 @@ export function OrganizationSettingsPage() {
|
|||
Включать, только если продаётся маркируемая категория (алкоголь, табак, лекарства).
|
||||
По умолчанию галка скрыта.
|
||||
</p>
|
||||
|
||||
<Checkbox
|
||||
label='Показывать мин/макс остатки на товаре'
|
||||
checked={form.showMinMaxStock}
|
||||
onChange={(v) => setForm({ ...form, showMinMaxStock: v })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 -mt-2">
|
||||
Если включено — на карточке товара есть поля «Минимальный / Максимальный остаток»
|
||||
для автозаказа. По умолчанию скрыто.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex gap-3 items-center">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
||||
import { ProductImageGallery } from '@/components/ProductImageGallery'
|
||||
import { generateEan13InternalPrefix2, generateBarcode } from '@/lib/barcode'
|
||||
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
||||
|
||||
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
|
||||
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
|
||||
|
|
@ -316,18 +316,16 @@ export function ProductEditPage() {
|
|||
</Grid>
|
||||
</Section>
|
||||
|
||||
{org.data?.showMinMaxStock && (
|
||||
<AdvancedSection>
|
||||
<Grid cols={4}>
|
||||
<Field label="Минимальный остаток (для уведомления)">
|
||||
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} placeholder="—" />
|
||||
</Field>
|
||||
<Field label="Максимальный остаток (для автозаказа)">
|
||||
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} placeholder="—" />
|
||||
</Field>
|
||||
</Grid>
|
||||
</AdvancedSection>
|
||||
)}
|
||||
<AdvancedSection>
|
||||
<Grid cols={4}>
|
||||
<Field label="Минимальный остаток (для уведомления)">
|
||||
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} placeholder="—" />
|
||||
</Field>
|
||||
<Field label="Максимальный остаток (для автозаказа)">
|
||||
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} placeholder="—" />
|
||||
</Field>
|
||||
</Grid>
|
||||
</AdvancedSection>
|
||||
|
||||
<Section
|
||||
title="Цены продажи"
|
||||
|
|
@ -391,10 +389,7 @@ export function ProductEditPage() {
|
|||
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Select value={b.type} onChange={(e) => {
|
||||
const newType = Number(e.target.value) as BarcodeType
|
||||
updateBarcode(i, { type: newType, code: generateBarcode(newType) })
|
||||
}}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Plus, Filter, X } from 'lucide-react'
|
|||
import { useCatalogList } from '@/lib/useCatalog'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||
import { packagingLabel, type Product } from '@/lib/types'
|
||||
import type { Product } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/products'
|
||||
|
||||
|
|
@ -117,19 +117,16 @@ export function ProductsPage() {
|
|||
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||
</div>
|
||||
)},
|
||||
{ header: 'Фасовка', width: '110px', sortKey: 'packaging', cell: (r) => packagingLabel[r.packaging] ?? '—' },
|
||||
{ header: 'Штрихкод', width: '160px', cell: (r) => (
|
||||
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
|
||||
)},
|
||||
{ header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => (
|
||||
r.purchasePrice != null
|
||||
? `${r.purchasePrice.toLocaleString('ru', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${r.purchaseCurrencyCode ?? ''}`.trim()
|
||||
: '—'
|
||||
)},
|
||||
{ header: 'Группа', width: '200px', sortKey: 'group', cell: (r) => r.productGroupName ?? '—' },
|
||||
{ header: 'Ед.', width: '70px', sortKey: 'unit', cell: (r) => r.unitName },
|
||||
]
|
||||
if (showVat) {
|
||||
baseColumns.push({ header: 'НДС', width: '90px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat.toFixed(2)}%` : '—' })
|
||||
}
|
||||
baseColumns.push(
|
||||
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
|
||||
{ header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
|
|
|
|||
Loading…
Reference in a new issue