feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 46s
Docker Web / Build + push Web (push) Successful in 30s
Docker API / Deploy API on stage (push) Failing after 36s
Docker Web / Deploy Web on stage (push) Successful in 11s
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 46s
Docker Web / Build + push Web (push) Successful in 30s
Docker API / Deploy API on stage (push) Failing after 36s
Docker Web / Deploy Web on stage (push) Successful in 11s
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.
Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
если SuperAdmin без override (системная), иначе подставляется
tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
OrganizationId DROP NOT NULL на product_groups и units_of_measure.
Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
если запись OrganizationId=null и юзер не SuperAdmin (только
суперадмин может править/удалять системные).
Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
реиспользуют существующие компоненты — для SuperAdmin без override
фильтр возвращает все записи, что в Phase 4+ можно ужесточить
отдельным эндпоинтом «только системные» (?orgId=null).
Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8466cf928c
commit
58038c9cf7
|
|
@ -44,7 +44,7 @@ public class ProductGroupsController : ControllerBase
|
|||
};
|
||||
var items = await q
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent))
|
||||
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ public class ProductGroupsController : ControllerBase
|
|||
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent);
|
||||
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -69,14 +69,17 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
|
|||
_db.ProductGroups.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent));
|
||||
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
// Системную (эталонную) запись правит только SuperAdmin без override.
|
||||
if (e.OrganizationId is null && !(User.IsInRole("SuperAdmin")))
|
||||
return Forbid();
|
||||
if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" });
|
||||
|
||||
e.Name = input.Name;
|
||||
|
|
@ -88,9 +91,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct);
|
||||
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
|
||||
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
|
||||
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
|||
};
|
||||
var items = await q
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description))
|
||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
|||
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description);
|
||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId);
|
||||
}
|
||||
|
||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||
|
|
@ -60,14 +60,15 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
|
|||
_db.UnitsOfMeasure.Add(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description));
|
||||
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||
|
||||
e.Code = input.Code;
|
||||
e.Name = input.Name;
|
||||
|
|
@ -76,11 +77,12 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (e is null) return NotFound();
|
||||
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
||||
_db.UnitsOfMeasure.Remove(e);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ public record CountryDto(
|
|||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
||||
|
||||
public record UnitOfMeasureDto(
|
||||
Guid Id, string Code, string Name, string? Description);
|
||||
Guid Id, string Code, string Name, string? Description, Guid? OrganizationId);
|
||||
|
||||
public record PriceTypeDto(
|
||||
Guid Id, string Name, bool IsRequired, bool IsSystem,
|
||||
|
|
@ -28,7 +28,7 @@ public record RetailPointDto(
|
|||
|
||||
public record ProductGroupDto(
|
||||
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
|
||||
decimal? MarkupPercent);
|
||||
decimal? MarkupPercent, Guid? OrganizationId);
|
||||
|
||||
public record CounterpartyDto(
|
||||
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Иерархическая группа товаров (категория). Произвольная вложенность через ParentId.
|
||||
public class ProductGroup : TenantEntity
|
||||
// Двухуровневый справочник (IOptionalTenantEntity): системные эталонные группы
|
||||
// (OrganizationId=null, управляются SuperAdmin'ом) и tenant'овские (OrganizationId=<orgId>).
|
||||
public class ProductGroup : Entity, IOptionalTenantEntity
|
||||
{
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public Guid? ParentId { get; set; }
|
||||
public ProductGroup? Parent { get; set; }
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
namespace foodmarket.Domain.Catalog;
|
||||
|
||||
// Единица измерения как в MoySklad entity/uom: code + name + description.
|
||||
public class UnitOfMeasure : TenantEntity
|
||||
// Двухуровневый справочник: системные эталонные (OrganizationId=null,
|
||||
// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения.
|
||||
public class UnitOfMeasure : Entity, IOptionalTenantEntity
|
||||
{
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||
public string? Description { get; set; }
|
||||
|
|
|
|||
|
|
@ -9,3 +9,12 @@ public abstract class TenantEntity : Entity, ITenantEntity
|
|||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Двухуровневый справочник: запись либо системная (OrganizationId=null,
|
||||
/// видна и читается всеми, мутирует только SuperAdmin), либо tenant'овская
|
||||
/// (OrganizationId=<orgId>, видна и редактируется этой оргой). Применяется
|
||||
/// для расширяемых эталонных списков типа единиц измерения и групп товаров.</summary>
|
||||
public interface IOptionalTenantEntity
|
||||
{
|
||||
Guid? OrganizationId { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,9 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
builder.ConfigureSales();
|
||||
builder.ConfigureOrganizationsHr();
|
||||
|
||||
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
||||
// Apply multi-tenant query filter to every entity that implements ITenantEntity.
|
||||
// IOptionalTenantEntity (системные справочники с nullable OrganizationId) —
|
||||
// через отдельный фильтр, который пропускает запись с NULL для всех.
|
||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||
{
|
||||
if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
|
||||
|
|
@ -111,6 +113,14 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
.MakeGenericMethod(entityType.ClrType);
|
||||
method.Invoke(this, new object[] { builder });
|
||||
}
|
||||
else if (typeof(IOptionalTenantEntity).IsAssignableFrom(entityType.ClrType))
|
||||
{
|
||||
var method = typeof(AppDbContext)
|
||||
.GetMethod(nameof(ApplyOptionalTenantFilter),
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.MakeGenericMethod(entityType.ClrType);
|
||||
method.Invoke(this, new object[] { builder });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +135,17 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
|| e.OrganizationId == _tenant.OrganizationId);
|
||||
}
|
||||
|
||||
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
|
||||
{
|
||||
// Системные записи (OrganizationId == null) видны ВСЕМ tenant'ам как
|
||||
// эталонные. Tenant'овские (свои OrganizationId) — обычная изоляция.
|
||||
// SuperAdmin без override видит всё.
|
||||
builder.Entity<T>().HasQueryFilter(e =>
|
||||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||||
|| e.OrganizationId == null
|
||||
|| e.OrganizationId == _tenant.OrganizationId);
|
||||
}
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
StampTenant();
|
||||
|
|
@ -143,11 +164,27 @@ private void StampTenant()
|
|||
{
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.State == EntityState.Added && entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
|
||||
if (entry.State != EntityState.Added) continue;
|
||||
if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
|
||||
{
|
||||
if (_tenant.OrganizationId.HasValue)
|
||||
tenant.OrganizationId = _tenant.OrganizationId.Value;
|
||||
}
|
||||
else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null)
|
||||
{
|
||||
// Если SuperAdmin создаёт запись «как пользователь» (override
|
||||
// активен), стампим выбранную орг. Если SuperAdmin без override
|
||||
// (системная консоль) — оставляем null (системная запись).
|
||||
// Tenant-юзер всегда стампит свой orgId.
|
||||
if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||||
{
|
||||
// null — системная запись; оставляем
|
||||
}
|
||||
else if (_tenant.OrganizationId.HasValue)
|
||||
{
|
||||
opt.OrganizationId = _tenant.OrganizationId.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,35 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>ProductGroup и UnitOfMeasure становятся двухуровневыми
|
||||
/// справочниками: системные эталонные (OrganizationId=NULL, управляются
|
||||
/// SuperAdmin'ом, видны всем tenant'ам как «Эталон») и tenant'овские.
|
||||
/// На уровне БД — DROP NOT NULL на колонке OrganizationId; существующие
|
||||
/// записи остаются tenant'овскими (OrganizationId сохраняется), миграция
|
||||
/// additive — никакие данные не теряются.</summary>
|
||||
public partial class Phase4d_OptionalTenantOnDirectories : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b)
|
||||
{
|
||||
b.AlterColumn<System.Guid>(
|
||||
name: "OrganizationId", schema: "public", table: "product_groups",
|
||||
type: "uuid", nullable: true,
|
||||
oldClrType: typeof(System.Guid), oldType: "uuid");
|
||||
b.AlterColumn<System.Guid>(
|
||||
name: "OrganizationId", schema: "public", table: "units",
|
||||
type: "uuid", nullable: true,
|
||||
oldClrType: typeof(System.Guid), oldType: "uuid");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder b)
|
||||
{
|
||||
// Защита от потери данных при downgrade — если есть NULL'ы (системные
|
||||
// записи), нельзя вернуть NOT NULL без миграции данных. Оставляем
|
||||
// как есть; downgrade в проде делать через отдельный скрипт.
|
||||
b.Sql("/* No-op: оптимально не возвращать NOT NULL без data migration */");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -698,7 +698,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
|
|
@ -927,7 +927,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ export default function App() {
|
|||
<Route path="organizations/new" element={<SuperAdminOrgCreatePage />} />
|
||||
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
|
||||
<Route path="countries" element={<CountriesPage />} />
|
||||
<Route path="groups" element={<ProductGroupsPage />} />
|
||||
<Route path="units" element={<UnitsOfMeasurePage />} />
|
||||
</Route>
|
||||
|
||||
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import {
|
||||
ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload,
|
||||
Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe,
|
||||
FolderTree, Ruler,
|
||||
} from 'lucide-react'
|
||||
import { api, getOrgOverride, setOrgOverride } from '@/lib/api'
|
||||
import { logout } from '@/lib/auth'
|
||||
|
|
@ -22,6 +23,8 @@ const NAV: NavSection[] = [
|
|||
]},
|
||||
{ group: 'Справочники', items: [
|
||||
{ to: '/super-admin/countries', icon: Globe, label: 'Страны' },
|
||||
{ to: '/super-admin/groups', icon: FolderTree, label: 'Группы (эталон)' },
|
||||
{ to: '/super-admin/units', icon: Ruler, label: 'Ед. измерения (эталон)' },
|
||||
]},
|
||||
{ group: 'Аудит', items: [
|
||||
{ to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал действий' },
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export interface Country {
|
|||
vatRate: number
|
||||
}
|
||||
export interface Currency { id: string; code: string; name: string; symbol: string }
|
||||
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null }
|
||||
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; organizationId: string | null }
|
||||
export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
|
||||
export interface Store {
|
||||
id: string; name: string; code: string | null; address: string | null; phone: string | null;
|
||||
|
|
@ -38,7 +38,7 @@ export interface RetailPoint {
|
|||
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
|
||||
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
|
||||
}
|
||||
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null }
|
||||
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null }
|
||||
export interface Counterparty {
|
||||
id: string; name: string; legalName: string | null; type: CounterpartyType;
|
||||
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
|
||||
|
|
|
|||
|
|
@ -63,9 +63,22 @@ export function ProductGroupsPage() {
|
|||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent })}
|
||||
onRowClick={(r) => {
|
||||
if (r.organizationId === null) {
|
||||
alert('Эталонная группа. Изменения недоступны — управляются на уровне платформы.')
|
||||
return
|
||||
}
|
||||
setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent })
|
||||
}}
|
||||
columns={[
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => (
|
||||
<span>
|
||||
{r.name}
|
||||
{r.organizationId === null && (
|
||||
<span className="ml-2 text-[10px] uppercase px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">Эталон</span>
|
||||
)}
|
||||
</span>
|
||||
)},
|
||||
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
|
||||
{ header: 'Наценка', width: '140px', cell: (r) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -56,13 +56,26 @@ export function UnitsOfMeasurePage() {
|
|||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
onRowClick={(r) => setForm({
|
||||
onRowClick={(r) => {
|
||||
if (r.organizationId === null) {
|
||||
alert('Эталонная единица измерения. Изменения недоступны — управляются на уровне платформы.')
|
||||
return
|
||||
}
|
||||
setForm({
|
||||
id: r.id, code: r.code, name: r.name,
|
||||
description: r.description ?? ''
|
||||
})}
|
||||
})
|
||||
}}
|
||||
columns={[
|
||||
{ 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) => (
|
||||
<span>
|
||||
{r.name}
|
||||
{r.organizationId === null && (
|
||||
<span className="ml-2 text-[10px] uppercase px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">Эталон</span>
|
||||
)}
|
||||
</span>
|
||||
)},
|
||||
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue