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