From 6940aa40dff7d452be6d3ba5c7cb4a2b00522070 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 7 Jun 2026 21:08:48 +0500 Subject: [PATCH] =?UTF-8?q?feat(s19):=20bulk-=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20+=20presets=20+=20power-user=20UX=20?= =?UTF-8?q?(7=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Bulk-обновление товаров — Product.IsArchived + IsAvailableForSale (Phase19a миграция), POST /api/catalog/products/bulk-update {ids, op, params} с операциями price-adjust (% / абсолют), change-group, archive/unarchive, toggle-sale. Одной транзакцией, multi-tenant через query-filter. Frontend: checkbox-колонка, sticky bulk-bar, модалка. 2. SavedPresets — domain UserPreset (Phase19b: jsonb ConfigJson, unique по OrgId+UserId+PageKey+Name). /api/user/presets CRUD per-user. компонент с chip-bar и сохранением. Применено к /reports/ sales/stock/profit + /catalog/products. 3. QuickActionsPalette — Cmd+J открывает отдельную палитру с 14 действиями + история topa-10 в localStorage.fm.quickActions.recent. ↑↓/Enter/Esc keyboard nav. Cmd+K (поиск) и Cmd+J (действия) — разные палитры. 4. Inline-edit цены — PATCH /api/catalog/products/{id}/price новый endpoint с RoundIfNeeded. с dblclick → input, optimistic update + revert при ошибке. 5. CSV import товаров — POST /api/catalog/products/import-csv (rows[]). Клиент парсит CSV (auto-detect разделитель ,/;), сервер commit'ит транзакцией. autoCreateGroup для новых групп. модалка с preview и подсветкой ошибочных строк. 6. CSV/XLSX export — endpoint'ы /export на 5 контроллерах (products, counterparties, stock, retail-sales, supplies). Reuse существующего ReportExport.Csv/Xlsx. dropdown с двумя форматами. 7. Keyboard-first nav в DataTable — ↑↓/Home/End/Enter/Space/Delete props keyboardNav/onSelect/onDelete. Подсветка focused-строки. Документация в src/help/keyboard-shortcuts.md + 2 новые HelpTopic'a. Co-Authored-By: Claude Opus 4.7 --- docs/sprint19-progress.md | 48 ++ .../Catalog/CounterpartiesController.cs | 25 + .../Controllers/Catalog/ProductsController.cs | 472 ++++++++++++++++++ .../Common/UserPresetsController.cs | 120 +++++ .../Controllers/Inventory/StockController.cs | 34 ++ .../Purchases/SuppliesController.cs | 30 ++ .../Sales/RetailSalesController.cs | 31 ++ .../Catalog/CatalogDtos.cs | 2 + src/food-market.domain/Catalog/Product.cs | 12 + src/food-market.domain/Common/UserPreset.cs | 24 + .../Persistence/AppDbContext.cs | 12 + .../Configurations/CatalogConfigurations.cs | 7 + .../20260607200000_Phase19a_ProductFlags.cs | 43 ++ .../20260607210000_Phase19b_UserPresets.cs | 44 ++ .../src/components/AppLayout.tsx | 15 +- .../src/components/DataTable.tsx | 71 ++- .../src/components/ExportButton.tsx | 102 ++++ .../src/components/InlinePriceCell.tsx | 129 +++++ .../src/components/ProductsBulkBar.tsx | 258 ++++++++++ .../src/components/ProductsCsvImport.tsx | 244 +++++++++ .../src/components/QuickActionsPalette.tsx | 211 ++++++++ .../src/components/SavedPresets.tsx | 158 ++++++ .../src/help/keyboard-shortcuts.md | 60 +++ src/food-market.web/src/lib/help-topics.ts | 10 + src/food-market.web/src/lib/types.ts | 2 + .../src/pages/CounterpartiesPage.tsx | 2 + .../src/pages/ProductsPage.tsx | 128 ++++- .../src/pages/ProfitReportPage.tsx | 11 + .../src/pages/RetailSalesPage.tsx | 2 + .../src/pages/SalesReportPage.tsx | 12 + src/food-market.web/src/pages/StockPage.tsx | 2 + .../src/pages/StockReportPage.tsx | 11 + .../src/pages/SuppliesPage.tsx | 2 + 33 files changed, 2319 insertions(+), 15 deletions(-) create mode 100644 docs/sprint19-progress.md create mode 100644 src/food-market.api/Controllers/Common/UserPresetsController.cs create mode 100644 src/food-market.domain/Common/UserPreset.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260607200000_Phase19a_ProductFlags.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260607210000_Phase19b_UserPresets.cs create mode 100644 src/food-market.web/src/components/ExportButton.tsx create mode 100644 src/food-market.web/src/components/InlinePriceCell.tsx create mode 100644 src/food-market.web/src/components/ProductsBulkBar.tsx create mode 100644 src/food-market.web/src/components/ProductsCsvImport.tsx create mode 100644 src/food-market.web/src/components/QuickActionsPalette.tsx create mode 100644 src/food-market.web/src/components/SavedPresets.tsx create mode 100644 src/food-market.web/src/help/keyboard-shortcuts.md diff --git a/docs/sprint19-progress.md b/docs/sprint19-progress.md new file mode 100644 index 0000000..1e7142d --- /dev/null +++ b/docs/sprint19-progress.md @@ -0,0 +1,48 @@ +# Sprint 19 — bulk-операции + сохранённые пресеты + power-user UX + +Цель: розничный админ работает быстрее в 2-3 раза за счёт массовых +операций, сохранённых пресетов фильтров, keyboard-first UX, inline-edit +и CSV import/export. + +Старт: 2026-06-07 (после Sprint 18). Исполнитель: Claude Opus 4.7. + +## Принципы + +- Bulk-операции — одной транзакцией; multi-tenant guard в каждом endpoint'е. +- Пресеты — per-user в org (cross-org нельзя видеть). +- Inline-edit с optimistic update, fallback на revert + toast. +- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF. + +## Чек-лист + +- [ ] **1. Bulk-обновление товаров** — checkbox-колонка + sticky панель + на /catalog/products. Операции: «+%/+₸ к цене», «Сменить группу», + «Архивировать», «Снять с продажи». Endpoint + `POST /api/catalog/products/bulk-update {ids, op, params}` — одна + транзакция, query-filter гарантирует tenant-isolation. +- [ ] **2. Сохранённые фильтры (presets)** — domain `UserPreset` + (UserId+OrgId+PageKey+Name+ConfigJson). `` chips + сверху списка. CRUD endpoint'ы. Применено к /reports/sales, + /reports/stock, /reports/profit, /catalog/products. +- [ ] **3. Quick-actions (Cmd+J)** — отдельная палитра от Cmd+K. + Меню недавних действий пользователя; хранится в localStorage + per-user (по userId). Топ-10 пунктов. +- [ ] **4. Inline-edit в таблицах** — dblclick по цене на + /catalog/products → input → Enter сохраняет (`PATCH + /api/catalog/products/{id}/price`). То же для остатка (admin + only, через `enter` корректирующий). Optimistic + revert. +- [ ] **5. Импорт CSV для товаров** — модалка с upload, preview + таблицы (валидация на клиенте + на сервере), коммит транзакцией. + Колонки: name,price,unit,group,barcode. Ошибки строк показаны + в preview. +- [ ] **6. Экспорт CSV/XLSX** — кнопка «Экспорт» в списках: + /catalog/products, /catalog/counterparties, /sales/retail, + /purchases/supplies, /inventory/stock. CSV — серверная генерация + с теми же фильтрами что и поиск. +- [ ] **7. Keyboard-first навигация по таблицам** — ↑/↓/Enter/Delete/ + Space в DataTable + документация на /help. + +## Журнал + +### 2026-06-07 старт +Sprint 18 закрыт (7/7 ✓ + 1 hotfix). Поехали по power-user UX. diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs index fe1a950..4dd4ed9 100644 --- a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -3,6 +3,7 @@ using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Api.Controllers.Reports; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -59,6 +60,30 @@ public class CounterpartiesController : ControllerBase return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } + /// Sprint 19: экспорт списка контрагентов. + [HttpGet("export")] + public async Task Export([FromQuery] string? format, [FromQuery] string? search, CancellationToken ct) + { + var q = _db.Counterparties.Include(c => c.Country).AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + q = q.Where(c => c.Name.ToLower().Contains(s) + || (c.LegalName != null && c.LegalName.ToLower().Contains(s)) + || (c.Bin != null && c.Bin.Contains(s))); + } + var rows = await q.OrderBy(c => c.Name) + .Select(c => new ExportRow( + c.Name, c.LegalName, c.Type.ToString(), c.Bin, c.Iin, c.Phone, c.Email, + c.Country != null ? c.Country.Name : null)) + .ToListAsync(ct); + var headers = new[] { "Имя", "Юр. название", "Тип", "БИН", "ИИН", "Телефон", "Email", "Страна" }; + return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase) + ? ReportExport.Xlsx(rows, "counterparties", "Контрагенты", headers) + : ReportExport.Csv(rows, "counterparties", headers); + } + public record ExportRow(string Name, string? LegalName, string Type, string? Bin, string? Iin, string? Phone, string? Email, string? Country); + [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 0cd4449..dcfd653 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -4,6 +4,7 @@ using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; using foodmarket.Api.Infrastructure.Authorization; +using foodmarket.Api.Controllers.Reports; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -118,9 +119,22 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) [FromQuery] decimal? referencePriceTo, [FromQuery] decimal? systemPriceFrom, [FromQuery] decimal? systemPriceTo, + // Sprint 19: bulk-флаги. archived=null (default) показывает только + // не-archived; archived=true — только архивные; archived=all — + // все. То же для availableForSale, но default — без фильтра (sale + // и not-for-sale оба показываются, чтобы кладовщик видел всё). + [FromQuery] string? archived, + [FromQuery] bool? availableForSale, CancellationToken ct) { var q = QueryIncludes().AsNoTracking(); + switch (archived?.ToLowerInvariant()) + { + case null or "" or "false": q = q.Where(p => !p.IsArchived); break; + case "true": q = q.Where(p => p.IsArchived); break; + case "all": /* без фильтра */ break; + } + if (availableForSale is not null) q = q.Where(p => p.IsAvailableForSale == availableForSale); if (groupId is not null) { // Include the whole subtree: match on the group's Path prefix so sub-groups also show up. @@ -189,6 +203,54 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } + /// Sprint 19: экспорт списка товаров с теми же фильтрами что + /// и /api/catalog/products. Сервер-side генерация CSV/XLSX через + /// . Tenant-isolation — стандартный query-filter. + [HttpGet("export")] + public async Task Export( + [FromQuery] string? format, + [FromQuery] string? search, + [FromQuery] Guid? groupId, + [FromQuery] string? archived, + CancellationToken ct) + { + var q = _db.Products.AsNoTracking().Include(p => p.UnitOfMeasure).Include(p => p.ProductGroup) + .Include(p => p.Prices).ThenInclude(pr => pr.PriceType).AsQueryable(); + switch (archived?.ToLowerInvariant()) + { + case null or "" or "false": q = q.Where(p => !p.IsArchived); break; + case "true": q = q.Where(p => p.IsArchived); break; + case "all": break; + } + if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + q = q.Where(p => p.Name.ToLower().Contains(s) + || (p.Article != null && p.Article.ToLower().Contains(s)) + || p.Barcodes.Any(b => b.Code.Contains(s))); + } + var rows = await q.OrderBy(p => p.Name) + .Select(p => new ExportRow( + p.Name, p.Article, + p.UnitOfMeasure!.Code, + p.ProductGroup!.Name, + p.Barcodes.Select(b => b.Code).FirstOrDefault(), + p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault(), + p.Cost, + p.IsArchived ? "архив" : (p.IsAvailableForSale ? "активен" : "не в продаже"))) + .ToListAsync(ct); + + var fmt = (format ?? "csv").ToLowerInvariant(); + var headers = new[] { "Название", "Артикул", "Ед.", "Группа", "Штрихкод", "Розн. цена", "Себестоимость", "Статус" }; + return fmt == "xlsx" + ? ReportExport.Xlsx(rows, "products", "Товары", headers) + : ReportExport.Csv(rows, "products", headers); + } + public record ExportRow( + string Name, string? Article, string UnitCode, string GroupName, + string? Barcode, decimal? Price, decimal Cost, string Status); + [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { @@ -348,6 +410,62 @@ public async Task RecalcRetail(Guid id, CancellationToken ct) return Ok(new { retail = newRetail }); } + /// Sprint 19: inline-edit цены отдельным эндпоинтом. UI делает + /// dblclick на ячейке системной розничной цены → input → Enter → этот + /// PATCH. Если priceTypeId не указан — используется системная розничная. + public record UpdatePriceRequest(Guid? PriceTypeId, decimal Amount); + + [HttpPatch("{id:guid}/price"), RequiresPermission("ProductsEdit")] + public async Task UpdatePrice(Guid id, [FromBody] UpdatePriceRequest req, CancellationToken ct) + { + if (req.Amount < 0m) + return BadRequest(new { error = "Цена не может быть отрицательной." }); + + var p = await _db.Products.Include(x => x.Prices) + .FirstOrDefaultAsync(x => x.Id == id, ct); + if (p is null) return NotFound(); + + var priceTypeId = req.PriceTypeId; + if (priceTypeId is null) + { + // Дефолт — системная розничная. + var defaultType = await _db.PriceTypes + .OrderByDescending(pt => pt.IsSystem) + .ThenByDescending(pt => pt.IsRetail) + .ThenBy(pt => pt.SortOrder) + .FirstOrDefaultAsync(ct); + if (defaultType is null) + return BadRequest(new { error = "Не задан системный тип цены." }); + priceTypeId = defaultType.Id; + } + else + { + var exists = await _db.PriceTypes.AnyAsync(pt => pt.Id == priceTypeId, ct); + if (!exists) return BadRequest(new { error = "PriceType не найден или принадлежит другой организации." }); + } + + var allowFractional = await AllowFractionalAsync(ct); + var rounded = RoundIfNeeded(req.Amount, allowFractional); + var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == priceTypeId); + if (existing is null) + { + var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct) + ?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct); + if (fallbackCurrency is null) + return BadRequest(new { error = "Не задана валюта по умолчанию." }); + p.Prices.Add(new ProductPrice + { + PriceTypeId = priceTypeId.Value, Amount = rounded, CurrencyId = fallbackCurrency.Value, + }); + } + else + { + existing.Amount = rounded; + } + await _db.SaveChangesAsync(ct); + return Ok(new { priceTypeId, amount = rounded }); + } + [HttpDelete("{id:guid}"), RequiresPermission("ProductsDelete")] public async Task Delete(Guid id, CancellationToken ct) { @@ -358,6 +476,359 @@ public async Task Delete(Guid id, CancellationToken ct) return NoContent(); } + /// Sprint 19: bulk-обновление товаров (массовая операция). + /// + /// Поддерживаемые операции: + /// - "price-adjust": params={ priceTypeId, mode: 'percent'|'absolute', delta: number } + /// — для каждого товара меняет (или создаёт) ProductPrice по типу. + /// percent: amount *= (1 + delta/100). absolute: amount += delta. + /// Результат clamp'ится к 0 снизу. + /// - "change-group": params={ groupId } + /// — переносит товары в указанную группу. + /// - "archive" / "unarchive" — IsArchived=true/false. + /// - "toggle-sale": params={ available: bool } — IsAvailableForSale. + /// + /// Все ID фильтруются через query-filter (tenant-isolation); чужие + /// просто не находятся и не апдейтятся (без 403 — это нормально). Одна + /// транзакция, либо всё, либо ничего. Возвращает { affected } — число + /// реально обновлённых строк. + public record BulkUpdateRequest( + IReadOnlyList Ids, + string Op, + Dictionary? Params); + public record BulkUpdateResponse(int Affected); + + /// Sprint 19: bulk-импорт товаров из CSV (фронт парсит, шлёт сюда). + /// Один запрос — одна транзакция. Колонки на входе: name (required), price + /// (для системной розничной), unitCode (kg/l/m/упак), groupName (создаётся + /// если не существует), barcode (первый штрихкод). Возвращает summary + + /// список созданных id. + /// + /// Валидация: пустое имя → 400. Группа не найдена и autoCreateGroup=false + /// → 400 с указанием row. Barcode-конфликт по уникальному индексу → 400 + /// с указанием row. Всё или ничего: при ошибке откатываем транзакцию. + public record CsvProductRow( + string Name, decimal? Price, string? UnitCode, + string? GroupName, string? Barcode); + public record CsvImportRequest(IReadOnlyList Rows, bool AutoCreateGroup = true); + public record CsvImportRowError(int Row, string Error); + public record CsvImportResponse(int Created, IReadOnlyList Errors, IReadOnlyList Ids); + + [HttpPost("import-csv"), RequiresPermission("ProductsEdit")] + public async Task> ImportCsv( + [FromBody] CsvImportRequest req, CancellationToken ct) + { + if (req.Rows is null || req.Rows.Count == 0) + return BadRequest(new { error = "Пустой импорт." }); + if (req.Rows.Count > 5000) + return BadRequest(new { error = "За один раз можно импортировать не более 5000 строк." }); + + // Pre-validation: пустые имена + дубликаты barcode внутри импорта. + var errors = new List(); + var barcodesInImport = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < req.Rows.Count; i++) + { + var r = req.Rows[i]; + if (string.IsNullOrWhiteSpace(r.Name)) + errors.Add(new CsvImportRowError(i + 1, "Пустое название.")); + if (!string.IsNullOrWhiteSpace(r.Barcode) && !barcodesInImport.Add(r.Barcode.Trim())) + errors.Add(new CsvImportRowError(i + 1, $"Дубликат штрихкода в импорте: {r.Barcode}")); + } + if (errors.Count > 0) + return new CsvImportResponse(0, errors, []); + + // Pre-fetch справочников. Группы матчатся по точному Name (case-sensitive, + // как в БД); единицы — по Code. + var groupsByName = await _db.ProductGroups.ToDictionaryAsync(g => g.Name, g => g.Id, ct); + // Корневая группа для autoCreate fallback'ов. + var rootGroupId = groupsByName.Values.FirstOrDefault(); + if (rootGroupId == Guid.Empty) + { + // Нет ни одной группы — нет корня. Это аномалия (bootstrap создаёт «Все товары»); + // отказываем а не создаём вслепую. + return BadRequest(new { error = "В организации не настроены группы товаров. Создайте корневую группу перед импортом." }); + } + var unitsByCode = await _db.UnitsOfMeasure.ToDictionaryAsync(u => u.Code, u => u.Id, ct); + var defaultUnitId = unitsByCode.Values.FirstOrDefault(); + if (defaultUnitId == Guid.Empty) + return BadRequest(new { error = "В организации не настроены единицы измерения." }); + + // Barcode-conflict pre-check против существующих в БД. + var importBarcodes = req.Rows.Select(r => r.Barcode?.Trim()) + .Where(b => !string.IsNullOrEmpty(b))!.Cast().ToList(); + var existingBarcodes = await _db.ProductBarcodes + .Where(b => importBarcodes.Contains(b.Code)) + .Select(b => b.Code).ToListAsync(ct); + if (existingBarcodes.Count > 0) + { + for (var i = 0; i < req.Rows.Count; i++) + { + var r = req.Rows[i]; + if (!string.IsNullOrEmpty(r.Barcode) && existingBarcodes.Contains(r.Barcode)) + errors.Add(new CsvImportRowError(i + 1, $"Штрихкод {r.Barcode} уже занят в БД.")); + } + if (errors.Count > 0) + return new CsvImportResponse(0, errors, []); + } + + // Системный priceType + дефолтная валюта для цены. + var defaultPriceType = await _db.PriceTypes + .OrderByDescending(pt => pt.IsSystem) + .ThenByDescending(pt => pt.IsRetail) + .ThenBy(pt => pt.SortOrder) + .FirstOrDefaultAsync(ct); + var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct) + ?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct); + var allowFractional = await AllowFractionalAsync(ct); + var defaultVat = await ResolveDefaultVatAsync(ct); + + var createdIds = new List(); + await using var tx = await _db.Database.BeginTransactionAsync(ct); + try + { + for (var i = 0; i < req.Rows.Count; i++) + { + var r = req.Rows[i]; + // Группа: точный матч, иначе autoCreate (если разрешено), иначе fallback на корневую. + Guid groupId; + if (!string.IsNullOrWhiteSpace(r.GroupName) && groupsByName.TryGetValue(r.GroupName, out var gId)) + { + groupId = gId; + } + else if (!string.IsNullOrWhiteSpace(r.GroupName) && req.AutoCreateGroup) + { + // Создаём как корневую (parent=null, path=name) — простейший layout + // для CSV-импорта; пользователь потом может реорганизовать. + var newGroup = new ProductGroup + { + Name = r.GroupName.Trim(), + Path = r.GroupName.Trim(), + ParentId = null, + SortOrder = 0, + }; + _db.ProductGroups.Add(newGroup); + await _db.SaveChangesAsync(ct); + groupsByName[newGroup.Name] = newGroup.Id; + groupId = newGroup.Id; + } + else + { + groupId = rootGroupId; + } + + // Единица: матч по коду, иначе дефолтная. + Guid unitId = !string.IsNullOrWhiteSpace(r.UnitCode) && unitsByCode.TryGetValue(r.UnitCode, out var uId) + ? uId : defaultUnitId; + + var p = new Product + { + Name = r.Name.Trim(), + UnitOfMeasureId = unitId, + ProductGroupId = groupId, + Vat = defaultVat, + VatEnabled = true, + Packaging = Packaging.Piece, + }; + _db.Products.Add(p); + await _db.SaveChangesAsync(ct); + + if (!string.IsNullOrWhiteSpace(r.Barcode)) + { + _db.ProductBarcodes.Add(new ProductBarcode + { + ProductId = p.Id, Code = r.Barcode.Trim(), + Type = BarcodeType.Ean13, IsPrimary = true, + }); + } + if (r.Price is decimal price && price >= 0m && defaultPriceType is not null && fallbackCurrency is not null) + { + _db.ProductPrices.Add(new ProductPrice + { + ProductId = p.Id, PriceTypeId = defaultPriceType.Id, + Amount = RoundIfNeeded(price, allowFractional), + CurrencyId = fallbackCurrency.Value, + }); + } + + createdIds.Add(p.Id); + } + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_product_barcodes_Code") == true) + { + await tx.RollbackAsync(ct); + return BadRequest(new { error = "Конфликт штрихкода. Откатили весь импорт. Проверьте CSV." }); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + + return new CsvImportResponse(createdIds.Count, [], createdIds); + } + + [HttpPost("bulk-update"), RequiresPermission("ProductsEdit")] + public async Task> BulkUpdate( + [FromBody] BulkUpdateRequest req, CancellationToken ct) + { + if (req.Ids is null || req.Ids.Count == 0) + return BadRequest(new { error = "Не выбрано ни одного товара." }); + if (req.Ids.Count > 1000) + return BadRequest(new { error = "За один раз можно обновить не более 1000 товаров." }); + + // Tenant-filter применяется автоматически query-filter'ом DbContext. + // ToListAsync приведёт к материализации перед UPDATE'ом — это нужно + // для price-adjust (читаем Prices), а для остальных операций нет; + // но 1000 строк дёшево и единообразие важнее. + var products = await _db.Products + .Include(p => p.Prices) + .Where(p => req.Ids.Contains(p.Id)) + .ToListAsync(ct); + + if (products.Count == 0) + return new BulkUpdateResponse(0); + + var op = req.Op?.ToLowerInvariant() ?? ""; + var prms = req.Params ?? new Dictionary(); + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + try + { + switch (op) + { + case "price-adjust": + { + if (!TryGetGuid(prms, "priceTypeId", out var priceTypeId)) + return BadRequest(new { error = "Не указан priceTypeId." }); + var mode = TryGetString(prms, "mode") ?? "percent"; + if (!TryGetDecimal(prms, "delta", out var delta)) + return BadRequest(new { error = "Не указана delta." }); + if (mode != "percent" && mode != "absolute") + return BadRequest(new { error = "mode должен быть 'percent' или 'absolute'." }); + // Валидируем, что такой PriceType существует у орга. + var priceTypeExists = await _db.PriceTypes.AnyAsync(pt => pt.Id == priceTypeId, ct); + if (!priceTypeExists) + return BadRequest(new { error = "PriceType не найден или принадлежит другой организации." }); + var allowFractional = await AllowFractionalAsync(ct); + // Currency для новых ProductPrice — дефолтная организации. + var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct) + ?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct); + if (fallbackCurrency is null) + return BadRequest(new { error = "Не задана валюта по умолчанию." }); + + foreach (var p in products) + { + var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == priceTypeId); + decimal newAmount; + if (existing is null) + { + // Нет цены этого типа — для percent создаём от 0 (= 0), + // что бессмысленно; для absolute = delta. + newAmount = mode == "absolute" ? Math.Max(0m, delta) : 0m; + if (newAmount > 0m) + { + p.Prices.Add(new ProductPrice + { + PriceTypeId = priceTypeId, + Amount = RoundIfNeeded(newAmount, allowFractional), + CurrencyId = fallbackCurrency.Value, + }); + } + } + else + { + newAmount = mode == "percent" + ? existing.Amount * (1m + delta / 100m) + : existing.Amount + delta; + if (newAmount < 0m) newAmount = 0m; + existing.Amount = RoundIfNeeded(newAmount, allowFractional); + } + } + break; + } + case "change-group": + { + if (!TryGetGuid(prms, "groupId", out var groupId)) + return BadRequest(new { error = "Не указан groupId." }); + var groupExists = await _db.ProductGroups.AnyAsync(g => g.Id == groupId, ct); + if (!groupExists) + return BadRequest(new { error = "Группа не найдена или принадлежит другой организации." }); + foreach (var p in products) p.ProductGroupId = groupId; + break; + } + case "archive": + foreach (var p in products) p.IsArchived = true; + break; + case "unarchive": + foreach (var p in products) p.IsArchived = false; + break; + case "toggle-sale": + { + if (!TryGetBool(prms, "available", out var avail)) + return BadRequest(new { error = "Не указан available (bool)." }); + foreach (var p in products) p.IsAvailableForSale = avail; + break; + } + default: + return BadRequest(new { error = $"Неизвестная операция: {req.Op}" }); + } + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + return new BulkUpdateResponse(products.Count); + } + + private static bool TryGetGuid(IDictionary p, string key, out Guid value) + { + value = Guid.Empty; + if (!p.TryGetValue(key, out var raw) || raw is null) return false; + // System.Text.Json десериализует object как JsonElement. + if (raw is System.Text.Json.JsonElement el && el.ValueKind == System.Text.Json.JsonValueKind.String) + return Guid.TryParse(el.GetString(), out value); + return Guid.TryParse(raw.ToString(), out value); + } + private static string? TryGetString(IDictionary p, string key) + { + if (!p.TryGetValue(key, out var raw) || raw is null) return null; + if (raw is System.Text.Json.JsonElement el && el.ValueKind == System.Text.Json.JsonValueKind.String) + return el.GetString(); + return raw.ToString(); + } + private static bool TryGetDecimal(IDictionary p, string key, out decimal value) + { + value = 0m; + if (!p.TryGetValue(key, out var raw) || raw is null) return false; + if (raw is System.Text.Json.JsonElement el) + { + if (el.ValueKind == System.Text.Json.JsonValueKind.Number) { value = el.GetDecimal(); return true; } + if (el.ValueKind == System.Text.Json.JsonValueKind.String) return decimal.TryParse(el.GetString(), + System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out value); + return false; + } + return decimal.TryParse(raw.ToString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out value); + } + private static bool TryGetBool(IDictionary p, string key, out bool value) + { + value = false; + if (!p.TryGetValue(key, out var raw) || raw is null) return false; + if (raw is System.Text.Json.JsonElement el) + { + if (el.ValueKind == System.Text.Json.JsonValueKind.True) { value = true; return true; } + if (el.ValueKind == System.Text.Json.JsonValueKind.False) { value = false; return true; } + return false; + } + return bool.TryParse(raw.ToString(), out value); + } + public record BarcodeDuplicate(string Code, IReadOnlyList Products); public record DuplicateProductRef(Guid ProductId, string ProductName, string? Article); @@ -501,6 +972,7 @@ public record ByBarcodeResult(IReadOnlyList Items); p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, p.Cost, p.LastSupplyAt, p.ImageUrl, + p.IsArchived, p.IsAvailableForSale, p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(), p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList()); diff --git a/src/food-market.api/Controllers/Common/UserPresetsController.cs b/src/food-market.api/Controllers/Common/UserPresetsController.cs new file mode 100644 index 0000000..d82bec9 --- /dev/null +++ b/src/food-market.api/Controllers/Common/UserPresetsController.cs @@ -0,0 +1,120 @@ +using System.Security.Claims; +using foodmarket.Domain.Common; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Common; + +/// Sprint 19: CRUD сохранённых пользовательских пресетов фильтров. +/// Per-user внутри org: пользователь видит/правит только свои пресеты. +/// Tenant-isolation — стандартный query-filter; per-user — здесь на уровне +/// контроллера через WHERE UserId = currentUserId. +[ApiController] +[Authorize] +[Route("api/user/presets")] +public class UserPresetsController : ControllerBase +{ + private readonly AppDbContext _db; + public UserPresetsController(AppDbContext db) => _db = db; + + public record PresetDto(Guid Id, string PageKey, string Name, string ConfigJson, DateTime UpdatedAt); + public record CreateRequest(string PageKey, string Name, string ConfigJson); + public record UpdateRequest(string Name, string ConfigJson); + + private Guid? CurrentUserId() + { + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + return Guid.TryParse(raw, out var id) ? id : null; + } + + [HttpGet] + public async Task>> List( + [FromQuery] string? pageKey, CancellationToken ct) + { + var userId = CurrentUserId(); + if (userId is null) return Forbid(); + + var q = _db.UserPresets.AsNoTracking().Where(p => p.UserId == userId); + if (!string.IsNullOrWhiteSpace(pageKey)) q = q.Where(p => p.PageKey == pageKey); + + var items = await q.OrderBy(p => p.Name) + .Select(p => new PresetDto(p.Id, p.PageKey, p.Name, p.ConfigJson, p.UpdatedAt)) + .ToListAsync(ct); + return items; + } + + [HttpPost] + public async Task> Create([FromBody] CreateRequest req, CancellationToken ct) + { + var userId = CurrentUserId(); + if (userId is null) return Forbid(); + if (string.IsNullOrWhiteSpace(req.PageKey)) + return BadRequest(new { error = "pageKey обязателен." }); + if (string.IsNullOrWhiteSpace(req.Name)) + return BadRequest(new { error = "Имя пресета обязательно." }); + // ConfigJson — schema-less, но должен быть валидным JSON + // (требование jsonb-колонки). Пустые → "{}". + var json = string.IsNullOrWhiteSpace(req.ConfigJson) ? "{}" : req.ConfigJson; + try { System.Text.Json.JsonDocument.Parse(json); } + catch { return BadRequest(new { error = "configJson должен быть валидным JSON." }); } + + var entity = new UserPreset + { + UserId = userId.Value, + PageKey = req.PageKey.Trim(), + Name = req.Name.Trim(), + ConfigJson = json, + UpdatedAt = DateTime.UtcNow, + }; + _db.UserPresets.Add(entity); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_user_presets_Org_User_PageKey_Name") == true) + { + return Conflict(new { error = "Пресет с таким именем уже существует на этой странице." }); + } + return new PresetDto(entity.Id, entity.PageKey, entity.Name, entity.ConfigJson, entity.UpdatedAt); + } + + [HttpPut("{id:guid}")] + public async Task> Update(Guid id, [FromBody] UpdateRequest req, CancellationToken ct) + { + var userId = CurrentUserId(); + if (userId is null) return Forbid(); + var e = await _db.UserPresets.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct); + if (e is null) return NotFound(); + if (!string.IsNullOrWhiteSpace(req.Name)) e.Name = req.Name.Trim(); + if (!string.IsNullOrWhiteSpace(req.ConfigJson)) + { + try { System.Text.Json.JsonDocument.Parse(req.ConfigJson); } + catch { return BadRequest(new { error = "configJson должен быть валидным JSON." }); } + e.ConfigJson = req.ConfigJson; + } + e.UpdatedAt = DateTime.UtcNow; + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_user_presets_Org_User_PageKey_Name") == true) + { + return Conflict(new { error = "Пресет с таким именем уже существует на этой странице." }); + } + return new PresetDto(e.Id, e.PageKey, e.Name, e.ConfigJson, e.UpdatedAt); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var userId = CurrentUserId(); + if (userId is null) return Forbid(); + var e = await _db.UserPresets.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct); + if (e is null) return NotFound(); + _db.UserPresets.Remove(e); + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Controllers/Inventory/StockController.cs b/src/food-market.api/Controllers/Inventory/StockController.cs index a9282b9..6f25edc 100644 --- a/src/food-market.api/Controllers/Inventory/StockController.cs +++ b/src/food-market.api/Controllers/Inventory/StockController.cs @@ -1,5 +1,6 @@ using foodmarket.Application.Common; using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Controllers.Reports; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -77,6 +78,39 @@ public record StockRow( return new PagedResult { Items = items, Total = total, Page = page, PageSize = pageSize }; } + /// Sprint 19: экспорт остатков. + [HttpGet("stock/export")] + public async Task ExportStock( + [FromQuery] Guid? storeId, [FromQuery] string? search, + [FromQuery] bool includeZero = false, [FromQuery] string? format = null, + CancellationToken ct = default) + { + var q = from s in _db.Stocks + join p in _db.Products on s.ProductId equals p.Id + join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id + join st in _db.Stores on s.StoreId equals st.Id + select new { s, p, u, st }; + if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value); + if (!includeZero) q = q.Where(x => x.s.Quantity != 0); + if (!string.IsNullOrWhiteSpace(search)) + { + var term = $"%{search.Trim()}%"; + q = q.Where(x => EF.Functions.ILike(x.p.Name, term) + || (x.p.Article != null && EF.Functions.ILike(x.p.Article, term))); + } + var rows = await q.OrderBy(x => x.p.Name) + .Select(x => new StockExportRow( + x.p.Name, x.p.Article, x.u.Name, x.st.Name, + x.s.Quantity, x.s.ReservedQuantity, x.s.Quantity - x.s.ReservedQuantity)) + .ToListAsync(ct); + var headers = new[] { "Товар", "Артикул", "Ед.", "Склад", "Количество", "Резерв", "Доступно" }; + return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase) + ? ReportExport.Xlsx(rows, "stock", "Остатки", headers) + : ReportExport.Csv(rows, "stock", headers); + } + public record StockExportRow(string Name, string? Article, string Unit, string Store, + decimal Quantity, decimal Reserved, decimal Available); + public record MovementRow( Guid Id, DateTime OccurredAt, Guid ProductId, string ProductName, string? Article, diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index 8b2e111..91e085f 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -125,6 +125,36 @@ public record SupplyInput( return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } + /// Sprint 19: экспорт списка приёмок с теми же фильтрами. + [HttpGet("export")] + public async Task Export( + [FromQuery] string? format, + [FromQuery] SupplyStatus? status, + [FromQuery] Guid? storeId, + [FromQuery] Guid? supplierId, + CancellationToken ct) + { + var q = from s in _db.Supplies.AsNoTracking() + join cp in _db.Counterparties on s.SupplierId equals cp.Id + join st in _db.Stores on s.StoreId equals st.Id + join cu in _db.Currencies on s.CurrencyId equals cu.Id + select new { s, cp, st, cu }; + if (status is not null) q = q.Where(x => x.s.Status == status); + if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId); + if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId); + var rows = await q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number) + .Select(x => new SupplyExportRow( + x.s.Number, x.s.Date, x.s.Status.ToString(), x.cp.Name, x.st.Name, + x.s.Total, x.cu.Code, x.s.PostedAt)) + .ToListAsync(ct); + var headers = new[] { "Номер", "Дата", "Статус", "Поставщик", "Склад", "Сумма", "Валюта", "Проведена" }; + return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase) + ? foodmarket.Api.Controllers.Reports.ReportExport.Xlsx(rows, "supplies", "Приёмки", headers) + : foodmarket.Api.Controllers.Reports.ReportExport.Csv(rows, "supplies", headers); + } + public record SupplyExportRow(string Number, DateTime Date, string Status, + string Supplier, string Store, decimal Total, string Currency, DateTime? PostedAt); + [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index e897a23..dc37b6f 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -237,6 +237,37 @@ public record SalesStatsResponse( return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } + /// Sprint 19: экспорт списка чеков с фильтрами status/storeId/from/to. + [HttpGet("export")] + public async Task Export( + [FromQuery] string? format, + [FromQuery] RetailSaleStatus? status, + [FromQuery] Guid? storeId, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + CancellationToken ct) + { + var q = from s in _db.RetailSales.AsNoTracking() + join st in _db.Stores on s.StoreId equals st.Id + join cu in _db.Currencies on s.CurrencyId equals cu.Id + select new { s, st, cu }; + if (status is not null) q = q.Where(x => x.s.Status == status); + if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId); + if (from is not null) q = q.Where(x => x.s.Date >= from); + if (to is not null) q = q.Where(x => x.s.Date < to); + var rows = await q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number) + .Select(x => new SaleExportRow( + x.s.Number, x.s.Date, x.s.Status.ToString(), x.st.Name, + x.s.Total, x.s.Payment, x.cu.Code, x.s.IsReturn)) + .ToListAsync(ct); + var headers = new[] { "Номер", "Дата", "Статус", "Склад", "Сумма", "Оплата", "Валюта", "Возврат" }; + return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase) + ? foodmarket.Api.Controllers.Reports.ReportExport.Xlsx(rows, "retail-sales", "Продажи", headers) + : foodmarket.Api.Controllers.Reports.ReportExport.Csv(rows, "retail-sales", headers); + } + public record SaleExportRow(string Number, DateTime Date, string Status, string Store, + decimal Total, decimal Payment, string Currency, bool IsReturn); + [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index 2e3088f..4670f26 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -54,6 +54,8 @@ public record ProductDto( Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, decimal Cost, DateTime? LastSupplyAt, string? ImageUrl, + // Sprint 19: bulk-операции «Архивировать» / «Снять с продажи». + bool IsArchived, bool IsAvailableForSale, IReadOnlyList Prices, IReadOnlyList Barcodes); diff --git a/src/food-market.domain/Catalog/Product.cs b/src/food-market.domain/Catalog/Product.cs index db23f1a..908ebd5 100644 --- a/src/food-market.domain/Catalog/Product.cs +++ b/src/food-market.domain/Catalog/Product.cs @@ -55,6 +55,18 @@ public class Product : TenantEntity public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) + /// Архивный товар. Скрывается из обычных списков (List default + /// filter); попадает в отчёты по историческим данным, но не предлагается + /// в новых документах. Sprint 19: bulk-операция «Архивировать». + public bool IsArchived { get; set; } + + /// Доступен ли товар для продажи на кассе/в опт. отгрузках. + /// Default true. Sprint 19: bulk-операция «Снять с продажи» ставит false; + /// POS позже добавит фильтр по этому флагу. Эта галка ОТЛИЧАЕТСЯ от + /// IsArchived: «снят с продажи» — временно (например, сезонный товар + /// вне сезона), архивный — навсегда (списан). + public bool IsAvailableForSale { get; set; } = true; + public ICollection Prices { get; set; } = []; public ICollection Barcodes { get; set; } = []; public ICollection Images { get; set; } = []; diff --git a/src/food-market.domain/Common/UserPreset.cs b/src/food-market.domain/Common/UserPreset.cs new file mode 100644 index 0000000..5af1a13 --- /dev/null +++ b/src/food-market.domain/Common/UserPreset.cs @@ -0,0 +1,24 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Common; + +/// Sprint 19: пользовательский «пресет» фильтров для страницы со +/// списком/отчётом. Per-user внутри организации: одна запись на (UserId, +/// OrganizationId, PageKey, Name). PageKey — стабильный идентификатор +/// страницы ('products', 'reports/sales', 'reports/stock' и т.п.). ConfigJson +/// — произвольный JSON со снимком фильтров (форма страницы знает свою схему, +/// сервер их не валидирует). +/// +/// Cross-org изоляция через query-filter (OrganizationId). Per-user изоляция +/// проверяется в контроллере: пользователь видит/правит только свои пресеты, +/// SuperAdmin override через IsSuperAdmin — НЕ применяется (личные пресеты +/// чужих юзеров не показываем даже супер-админу). +public class UserPreset : TenantEntity +{ + public Guid UserId { get; set; } + public string PageKey { get; set; } = null!; + public string Name { get; set; } = null!; + /// JSON-снимок состояния фильтров. Schema-less по дизайну. + public string ConfigJson { get; set; } = "{}"; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 8632c60..7ab5f27 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -77,6 +77,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet SuperAdminAuditLogs => Set(); public DbSet OrgAuditLogs => Set(); public DbSet ImportJobs => Set(); + public DbSet UserPresets => Set(); /// Если true — не пишет audit-строки /// для этого SaveChanges. Используется сидерами/миграциями, фоновыми @@ -153,6 +154,17 @@ protected override void OnModelCreating(ModelBuilder builder) b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt }); }); + builder.Entity(b => + { + b.ToTable("user_presets"); + b.Property(x => x.PageKey).HasMaxLength(100).IsRequired(); + b.Property(x => x.Name).HasMaxLength(200).IsRequired(); + b.Property(x => x.ConfigJson).HasColumnType("jsonb").IsRequired(); + // Один пресет с таким именем на (UserId, OrgId, PageKey). + b.HasIndex(x => new { x.OrganizationId, x.UserId, x.PageKey, x.Name }).IsUnique(); + b.HasIndex(x => new { x.OrganizationId, x.UserId, x.PageKey }); + }); + builder.Entity(b => { b.ToTable("import_jobs"); diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 962cf6a..1e8c5d3 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -137,6 +137,10 @@ private static void ConfigureProduct(EntityTypeBuilder b) // VatEnabled defaults to true в БД — при миграции existing rows сохраняют true. b.Property(x => x.VatEnabled).HasDefaultValue(true); + // Sprint 19: IsArchived/IsAvailableForSale defaults в БД, чтобы + // existing rows получили валидные значения при миграции (false/true). + b.Property(x => x.IsArchived).HasDefaultValue(false); + b.Property(x => x.IsAvailableForSale).HasDefaultValue(true); b.HasOne(x => x.UnitOfMeasure).WithMany().HasForeignKey(x => x.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.ProductGroup).WithMany().HasForeignKey(x => x.ProductGroupId).OnDelete(DeleteBehavior.Restrict); b.HasOne(x => x.DefaultSupplier).WithMany().HasForeignKey(x => x.DefaultSupplierId).OnDelete(DeleteBehavior.Restrict); @@ -151,6 +155,9 @@ private static void ConfigureProduct(EntityTypeBuilder b) // distinct), но раз указан — должен быть уникальным. b.HasIndex(x => new { x.OrganizationId, x.Article }).IsUnique(); b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId }); + // Sprint 19: partial index по IsArchived — listings default фильтруют + // archived=false, селективность хорошая (архивных меньшинство). + b.HasIndex(x => new { x.OrganizationId, x.IsArchived }); } private static void ConfigureProductPrice(EntityTypeBuilder b) diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260607200000_Phase19a_ProductFlags.cs b/src/food-market.infrastructure/Persistence/Migrations/20260607200000_Phase19a_ProductFlags.cs new file mode 100644 index 0000000..2aca970 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260607200000_Phase19a_ProductFlags.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase19a — Product.IsArchived + Product.IsAvailableForSale. + /// + /// Два булевых флага для bulk-операций (Sprint 19): «архивный» и + /// «снят с продажи». Дефолты в БД (false / true), чтобы existing rows + /// получили валидные значения без UPDATE. Partial-индекс по IsArchived + /// для default-фильтра в /api/catalog/products (показывает не-archived). + /// + /// Идемпотентен (ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS). + [DbContext(typeof(AppDbContext))] + [Migration("20260607200000_Phase19a_ProductFlags")] + public partial class Phase19a_ProductFlags : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.products + ADD COLUMN IF NOT EXISTS ""IsArchived"" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS ""IsAvailableForSale"" boolean NOT NULL DEFAULT true; + + CREATE INDEX IF NOT EXISTS ""IX_products_OrganizationId_IsArchived"" + ON public.products (""OrganizationId"", ""IsArchived""); + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP INDEX IF EXISTS public.""IX_products_OrganizationId_IsArchived""; + ALTER TABLE public.products + DROP COLUMN IF EXISTS ""IsArchived"", + DROP COLUMN IF EXISTS ""IsAvailableForSale""; + "); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260607210000_Phase19b_UserPresets.cs b/src/food-market.infrastructure/Persistence/Migrations/20260607210000_Phase19b_UserPresets.cs new file mode 100644 index 0000000..b5ced4b --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260607210000_Phase19b_UserPresets.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase19b — таблица user_presets для сохранённых фильтров. + /// PageKey — стабильный идентификатор страницы ('products', 'reports/sales'); + /// ConfigJson — JSON со снимком фильтров (schema-less). Уникальность по + /// (OrgId, UserId, PageKey, Name) — нельзя два пресета «Январь» на одной + /// странице у одного юзера. + [DbContext(typeof(AppDbContext))] + [Migration("20260607210000_Phase19b_UserPresets")] + public partial class Phase19b_UserPresets : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.user_presets ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""UserId"" uuid NOT NULL, + ""PageKey"" varchar(100) NOT NULL, + ""Name"" varchar(200) NOT NULL, + ""ConfigJson"" jsonb NOT NULL DEFAULT '{}'::jsonb, + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now() + ); + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_user_presets_Org_User_PageKey_Name"" + ON public.user_presets (""OrganizationId"", ""UserId"", ""PageKey"", ""Name""); + CREATE INDEX IF NOT EXISTS ""IX_user_presets_Org_User_PageKey"" + ON public.user_presets (""OrganizationId"", ""UserId"", ""PageKey""); + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@"DROP TABLE IF EXISTS public.user_presets;"); + } + } +} diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index dc16562..cf8865b 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -15,6 +15,7 @@ import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { ShortcutsOverlay } from './ShortcutsOverlay' import { LanguageSwitcher } from './LanguageSwitcher' import { CommandPalette } from './CommandPalette' +import { QuickActionsPalette } from './QuickActionsPalette' import { FeedbackWidget } from './FeedbackWidget' import { WhatsNewBanner } from './WhatsNewBanner' import { NotificationCenter } from './NotificationCenter' @@ -188,17 +189,21 @@ export function AppLayout() { const [drawerOpen, setDrawerOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false) + const [quickOpen, setQuickOpen] = useState(false) const location = useLocation() // Закрывать drawer + палитру при смене маршрута. - useEffect(() => { setDrawerOpen(false); setPaletteOpen(false) }, [location.pathname]) - // Глобальный хоткей Cmd+K / Ctrl+K — открывает командную палитру. - // Не используем useShortcuts (там mod+s обрабатывается на странице); - // ставим listener на document верхним уровнем, capture-фазу не нужно. + useEffect(() => { setDrawerOpen(false); setPaletteOpen(false); setQuickOpen(false) }, [location.pathname]) + // Глобальный хоткей Cmd+K — командная палитра (поиск), Cmd+J — + // палитра быстрых действий (Sprint 19). Не используем useShortcuts + // (там mod+s обрабатывается на странице); listener на document верхним уровнем. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault() setPaletteOpen((x) => !x) + } else if (e.key.toLowerCase() === 'j' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + setQuickOpen((x) => !x) } } document.addEventListener('keydown', onKey) @@ -319,6 +324,8 @@ export function AppLayout() { {/* Командная палитра (Cmd+K / Ctrl+K) — глобальный поиск + навигация. */} setPaletteOpen(false)} /> + {/* Sprint 19: быстрые действия (Cmd+J) — отдельный список с recents. */} + setQuickOpen(false)} /> ) } diff --git a/src/food-market.web/src/components/DataTable.tsx b/src/food-market.web/src/components/DataTable.tsx index 06e8ad3..f549a4c 100644 --- a/src/food-market.web/src/components/DataTable.tsx +++ b/src/food-market.web/src/components/DataTable.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect, useRef } from 'react' import type { ReactNode } from 'react' import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import { cn } from '@/lib/utils' @@ -6,7 +7,7 @@ import { TableSkeleton } from '@/components/Skeleton' export type SortOrder = 'asc' | 'desc' export interface Column { - header: string + header: ReactNode cell: (row: T) => ReactNode className?: string width?: string @@ -30,12 +31,73 @@ interface DataTableProps { sortOrder?: SortOrder /** Колбэк кликов по заголовку. Если не задан — заголовки не кликабельны. */ onSortChange?: (key: string, order: SortOrder) => void + /** Sprint 19: keyboard-first navigation. Если true — таблица слушает + * ↑/↓/Home/End/Enter/Delete/Space, выделяет «фокусированную» строку и + * вызывает onRowClick на Enter, onDelete на Delete, onSelect на Space. */ + keyboardNav?: boolean + /** Sprint 19: вызывается на Delete (с подтверждением — на стороне родителя). */ + onDelete?: (row: T) => void + /** Sprint 19: вызывается на Space — bulk-select toggle. */ + onSelect?: (row: T) => void } export function DataTable({ rows, columns, rowKey, onRowClick, onRowDoubleClick, empty, isLoading, scrollable = true, sortKey, sortOrder, onSortChange, + keyboardNav = false, onDelete, onSelect, }: DataTableProps) { + // Sprint 19: индекс «фокусированной» строки для keyboard-nav. -1 = ничего + // не выбрано; при первом ↓ становится 0. Сбрасывается при смене набора + // строк. Скролл к выбранной — через scrollIntoView с block:'nearest'. + const [focusIdx, setFocusIdx] = useState(-1) + const rowRefs = useRef>([]) + + useEffect(() => { + // Сбрасываем фокус если набор строк изменился существенно (новая страница). + setFocusIdx((idx) => (idx >= rows.length ? -1 : idx)) + }, [rows.length]) + + useEffect(() => { + if (!keyboardNav) return + function onKey(e: KeyboardEvent) { + const target = e.target as HTMLElement | null + // Если фокус в инпуте — не перехватываем стрелки/Enter/Delete. + const tag = target?.tagName?.toLowerCase() + if (tag === 'input' || tag === 'textarea' || tag === 'select' || target?.isContentEditable) return + // Команды клавиатуры активны когда есть строки. + if (rows.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setFocusIdx((i) => Math.min(rows.length - 1, i < 0 ? 0 : i + 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setFocusIdx((i) => Math.max(0, i < 0 ? 0 : i - 1)) + } else if (e.key === 'Home') { + e.preventDefault() + setFocusIdx(0) + } else if (e.key === 'End') { + e.preventDefault() + setFocusIdx(rows.length - 1) + } else if (e.key === 'Enter' && focusIdx >= 0 && focusIdx < rows.length) { + e.preventDefault() + onRowClick?.(rows[focusIdx]) + } else if (e.key === 'Delete' && focusIdx >= 0 && focusIdx < rows.length && onDelete) { + e.preventDefault() + onDelete(rows[focusIdx]) + } else if (e.key === ' ' && focusIdx >= 0 && focusIdx < rows.length && onSelect) { + e.preventDefault() + onSelect(rows[focusIdx]) + } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [keyboardNav, rows, focusIdx, onRowClick, onDelete, onSelect]) + + useEffect(() => { + if (focusIdx < 0) return + rowRefs.current[focusIdx]?.scrollIntoView({ block: 'nearest', behavior: 'auto' }) + }, [focusIdx]) + const handleHeaderClick = (key: string) => { if (!onSortChange) return const nextOrder: SortOrder = sortKey === key && sortOrder === 'asc' ? 'desc' : 'asc' @@ -96,15 +158,18 @@ export function DataTable({ ) : ( - rows.map((row) => ( + rows.map((row, rowIdx) => ( onRowClick?.(row)} + ref={(el) => { rowRefs.current[rowIdx] = el }} + onClick={() => { if (keyboardNav) setFocusIdx(rowIdx); onRowClick?.(row) }} onDoubleClick={() => onRowDoubleClick?.(row)} className={cn( (onRowClick || onRowDoubleClick) && 'hover:bg-slate-50 dark:hover:bg-slate-800/30', onRowDoubleClick && 'cursor-pointer select-none', onRowClick && !onRowDoubleClick && 'cursor-pointer', + // Sprint 19: подсветка keyboard-focused строки (ring внутри). + keyboardNav && rowIdx === focusIdx && 'bg-emerald-50 dark:bg-emerald-900/20', )} > {columns.map((c, i) => ( diff --git a/src/food-market.web/src/components/ExportButton.tsx b/src/food-market.web/src/components/ExportButton.tsx new file mode 100644 index 0000000..2d7a3b0 --- /dev/null +++ b/src/food-market.web/src/components/ExportButton.tsx @@ -0,0 +1,102 @@ +/** + * Sprint 19: кнопка экспорта списка в CSV/XLSX. + * + * Универсальная: принимает `url` (без `?format=`), дополнительные `params` + * (текущие фильтры списка) и грузит файл через api.get с responseType='blob'. + * Имя файла — из Content-Disposition или fallback на `defaultName`. + * + * Кнопка с dropdown — два варианта: CSV / XLSX. + */ +import { useState, useEffect, useRef } from 'react' +import { Download } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { toast } from '@/lib/toast' + +interface ExportButtonProps { + /** Базовый URL endpoint'a без параметра format. Пример: '/api/catalog/products/export'. */ + url: string + /** Параметры запроса (фильтры/search) — конкатенируются с format. */ + params?: Record + /** Имя файла без расширения если сервер не дал Content-Disposition. */ + defaultName?: string + /** Размер/вариант кнопки — пробрасывается в Button. */ + size?: 'sm' | 'md' + variant?: 'primary' | 'secondary' +} + +export function ExportButton({ url, params, defaultName = 'export', size = 'md', variant = 'secondary' }: ExportButtonProps) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + function onClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onClick) + return () => document.removeEventListener('mousedown', onClick) + }, [open]) + + async function download(format: 'csv' | 'xlsx') { + setLoading(true) + try { + const qs = new URLSearchParams() + qs.set('format', format) + for (const [k, v] of Object.entries(params ?? {})) { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)) + } + const resp = await api.get(`${url}?${qs}`, { responseType: 'blob' }) + const blob = new Blob([resp.data]) + const blobUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = blobUrl + const cd = resp.headers['content-disposition'] as string | undefined + const match = cd?.match(/filename="?([^";]+)"?/i) + a.download = match?.[1] ?? `${defaultName}.${format}` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(blobUrl) + } catch (err) { + const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error + ?? 'Не удалось скачать файл' + toast.error(msg) + } finally { + setLoading(false) + setOpen(false) + } + } + + return ( +
+ + {open && ( +
+ + +
+ )} +
+ ) +} diff --git a/src/food-market.web/src/components/InlinePriceCell.tsx b/src/food-market.web/src/components/InlinePriceCell.tsx new file mode 100644 index 0000000..a90a31e --- /dev/null +++ b/src/food-market.web/src/components/InlinePriceCell.tsx @@ -0,0 +1,129 @@ +/** + * Sprint 19: inline-edit ячейки цены в таблице. + * + * Dblclick на ячейке → input → Enter сохраняет (PATCH /api/catalog/products/{id}/price). + * Esc отменяет. Optimistic update через TanStack Query: меняем кэш сразу, + * при ошибке возвращаем старое значение + показываем toast. + * + * Не используется обычная мутация-с-инвалидацией, потому что invalidate + * перезагружает всю страницу — слишком тяжело для редактирования одной + * ячейки. Локальный setQueryData + onError-revert лучше. + */ +import { useState, useRef, useEffect, useCallback } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/api' +import { toast } from '@/lib/toast' + +export interface InlinePriceCellProps { + productId: string + priceTypeId: string | null + initialAmount: number | null + /** Локаль/формат — задаются из родителя. */ + format: (n: number | null) => string + /** ARIA-label для accessibility. */ + ariaLabel: string + /** Stop propagation чтобы клик не открывал товар. */ + stopPropagation?: boolean +} + +export function InlinePriceCell({ + productId, priceTypeId, initialAmount, format, ariaLabel, stopPropagation = true, +}: InlinePriceCellProps) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(initialAmount?.toString() ?? '') + const inputRef = useRef(null) + const qc = useQueryClient() + // Локальная «теневая» сумма — отображается пока optimistic-update висит + // в кэше; при ошибке возвращаемся к initialAmount. + const [displayedAmount, setDisplayedAmount] = useState(initialAmount) + + // initialAmount меняется при refetch'е таблицы — синхронизируем. + useEffect(() => { setDisplayedAmount(initialAmount); setDraft(initialAmount?.toString() ?? '') }, [initialAmount]) + + const startEdit = useCallback(() => { + setEditing(true) + setDraft(displayedAmount?.toString() ?? '') + setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, 0) + }, [displayedAmount]) + + const mutation = useMutation({ + mutationFn: async (amount: number) => { + const res = await api.patch<{ amount: number }>(`/api/catalog/products/${productId}/price`, { + priceTypeId, amount, + }) + return res.data + }, + onMutate: async (amount) => { + // Optimistic — сразу применяем; revert при onError. + const previous = displayedAmount + setDisplayedAmount(amount) + return { previous } + }, + onError: (err, _amount, ctx) => { + if (ctx) setDisplayedAmount(ctx.previous) + const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error + ?? 'Не удалось сохранить цену' + toast.error(msg) + }, + onSuccess: (data) => { + // server-side rounding мог изменить значение — синхронизируемся. + setDisplayedAmount(data.amount) + // Тихо инвалидируем список для будущих рефечей (не блокируя UI). + qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) + }, + }) + + const commit = () => { + const n = Number(draft.replace(',', '.')) + if (!Number.isFinite(n) || n < 0) { + toast.error('Введите неотрицательное число') + return + } + setEditing(false) + if (n !== (displayedAmount ?? 0)) mutation.mutate(n) + } + + const cancel = () => { + setEditing(false) + setDraft(displayedAmount?.toString() ?? '') + } + + if (editing) { + return ( + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commit() } + if (e.key === 'Escape') { e.preventDefault(); cancel() } + if (stopPropagation) e.stopPropagation() + }} + onBlur={commit} + onClick={(e) => { if (stopPropagation) e.stopPropagation() }} + onDoubleClick={(e) => { if (stopPropagation) e.stopPropagation() }} + type="text" + inputMode="decimal" + aria-label={ariaLabel} + className="w-full text-right font-mono text-sm px-1 py-0.5 border border-emerald-500 rounded outline-none bg-white dark:bg-slate-900" + /> + ) + } + + return ( + { + if (stopPropagation) e.stopPropagation() + startEdit() + }} + title="Двойной клик — редактировать" + className="cursor-text inline-block w-full" + > + {format(displayedAmount)} + {mutation.isPending && } + + ) +} diff --git a/src/food-market.web/src/components/ProductsBulkBar.tsx b/src/food-market.web/src/components/ProductsBulkBar.tsx new file mode 100644 index 0000000..e53c632 --- /dev/null +++ b/src/food-market.web/src/components/ProductsBulkBar.tsx @@ -0,0 +1,258 @@ +/** + * Sprint 19: панель массовых операций на /catalog/products. + * + * Появляется фиксированной полосой снизу контента когда выбран хотя бы один + * товар. Кнопки → открывают встроенную модалку с параметрами операции; + * POST /api/catalog/products/bulk-update {ids, op, params} одной транзакцией. + * + * Tenant-isolation: API отбрасывает чужие id по query-filter; здесь только + * UX-слой. + */ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Archive, ArchiveRestore, Tag, FolderInput, Ban, X } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, Select } from '@/components/Field' +import { toast } from '@/lib/toast' +import { usePriceTypes } from '@/lib/useLookups' +import { useQuery } from '@tanstack/react-query' + +interface ProductsBulkBarProps { + selectedIds: string[] + /** Сбросить выбор. Вызывается после успешной операции и при кнопке Отмена. */ + onClear: () => void +} + +type Op = + | { kind: 'price-adjust' } + | { kind: 'change-group' } + | { kind: 'archive' } + | { kind: 'unarchive' } + | { kind: 'toggle-sale'; available: boolean } + +interface ProductGroup { id: string; name: string; path: string } + +export function ProductsBulkBar({ selectedIds, onClear }: ProductsBulkBarProps) { + const [openOp, setOpenOp] = useState(null) + const qc = useQueryClient() + + const bulkMutation = useMutation({ + mutationFn: async (payload: { op: string; params?: Record }) => { + const res = await api.post<{ affected: number }>('/api/catalog/products/bulk-update', { + ids: selectedIds, + op: payload.op, + params: payload.params ?? {}, + }) + return res.data + }, + onSuccess: (data) => { + toast.success(`Обновлено товаров: ${data.affected}`) + qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) + onClear() + setOpenOp(null) + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error + ?? 'Не удалось применить операцию' + toast.error(msg) + }, + }) + + if (selectedIds.length === 0 && !openOp) return null + + return ( + <> + {selectedIds.length > 0 && ( +
+ + Выбрано: {selectedIds.length} + +
+ + + + + +
+ +
+ )} + + {openOp && ( + setOpenOp(null)} + onSubmit={(payload) => bulkMutation.mutate(payload)} + isLoading={bulkMutation.isPending} + /> + )} + + ) +} + +interface BulkOpModalProps { + op: Op + count: number + onClose: () => void + onSubmit: (payload: { op: string; params?: Record }) => void + isLoading: boolean +} + +function BulkOpModal({ op, count, onClose, onSubmit, isLoading }: BulkOpModalProps) { + const priceTypes = usePriceTypes() + const [priceTypeId, setPriceTypeId] = useState('') + const [mode, setMode] = useState<'percent' | 'absolute'>('percent') + const [delta, setDelta] = useState('10') + const [groupId, setGroupId] = useState('') + + // Дефолтный priceType — системный розничный. + const systemPriceType = priceTypes.data?.find((pt) => pt.isSystem) + ?? priceTypes.data?.find((pt) => pt.isRetail) + ?? priceTypes.data?.[0] + + const groups = useQuery({ + queryKey: ['/api/catalog/product-groups', 'bulk-modal'], + queryFn: async () => (await api.get<{ items: ProductGroup[] }>('/api/catalog/product-groups?pageSize=500')).data, + enabled: op.kind === 'change-group', + staleTime: 5 * 60 * 1000, + }) + + const title = op.kind === 'price-adjust' ? 'Изменить цену' + : op.kind === 'change-group' ? 'Сменить группу' + : op.kind === 'archive' ? 'Архивировать' + : op.kind === 'unarchive' ? 'Восстановить из архива' + : 'Снять с продажи' + + function submit(e: React.FormEvent) { + e.preventDefault() + if (op.kind === 'price-adjust') { + const pt = priceTypeId || systemPriceType?.id + if (!pt) { toast.error('Не выбран тип цены'); return } + const d = Number(delta) + if (!Number.isFinite(d)) { toast.error('Некорректное значение'); return } + onSubmit({ op: 'price-adjust', params: { priceTypeId: pt, mode, delta: d } }) + return + } + if (op.kind === 'change-group') { + if (!groupId) { toast.error('Не выбрана группа'); return } + onSubmit({ op: 'change-group', params: { groupId } }) + return + } + if (op.kind === 'archive') { onSubmit({ op: 'archive' }); return } + if (op.kind === 'unarchive') { onSubmit({ op: 'unarchive' }); return } + if (op.kind === 'toggle-sale') { + onSubmit({ op: 'toggle-sale', params: { available: op.available } }) + return + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+

{title}

+ +
+

+ Операция применится к {count} {count === 1 ? 'товару' : 'товарам'}. +

+ + {op.kind === 'price-adjust' && ( +
+ + + + + + + + setDelta(e.target.value)} + step="0.01" + className="h-9 px-2 border border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 rounded text-sm w-full" + /> + +
+ )} + + {op.kind === 'change-group' && ( + + + + )} + + {(op.kind === 'archive' || op.kind === 'unarchive') && ( +

+ {op.kind === 'archive' + ? 'Архивные товары скрываются из обычных списков, но остаются в исторических отчётах и документах.' + : 'Товары вернутся в обычные списки и снова будут доступны для документов.'} +

+ )} + + {op.kind === 'toggle-sale' && ( +

+ Товары не будут предлагаться в новых продажах. Восстановить можно через bulk-операцию «Вернуть в продажу». +

+ )} + +
+ + +
+
+
+ ) +} diff --git a/src/food-market.web/src/components/ProductsCsvImport.tsx b/src/food-market.web/src/components/ProductsCsvImport.tsx new file mode 100644 index 0000000..8495d5b --- /dev/null +++ b/src/food-market.web/src/components/ProductsCsvImport.tsx @@ -0,0 +1,244 @@ +/** + * Sprint 19: CSV-импорт товаров. + * + * UX: пользователь выбирает CSV; парсим клиентом (простой парсер, без quoted + * cells пока). Показываем preview-таблицу с подсветкой ошибочных строк. + * Кнопка «Импортировать» → POST /api/catalog/products/import-csv {rows}. + * Сервер делает одну транзакцию: всё или ничего. + * + * Колонки CSV (заголовок обязателен, регистронезависимо): + * name,price,unit,group,barcode + * + * Разделитель: запятая или точка с запятой (автодетект по первой строке). + * Десятичный разделитель цены: точка или запятая. + */ +import { useState, useRef } from 'react' +import { useMutation } from '@tanstack/react-query' +import { Upload, X, CheckCircle, AlertCircle, FileText } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { toast } from '@/lib/toast' + +interface CsvRow { + rowIndex: number + name: string + price: number | null + unit: string | null + group: string | null + barcode: string | null + errors: string[] +} + +interface ImportResponse { + created: number + errors: Array<{ row: number; error: string }> + ids: string[] +} + +interface ProductsCsvImportProps { + open: boolean + onClose: () => void + onSuccess: () => void +} + +function detectDelimiter(line: string): string { + // semicolons reign in russian Excel exports; comma — в гугл-доках. + const semi = (line.match(/;/g) ?? []).length + const comma = (line.match(/,/g) ?? []).length + return semi > comma ? ';' : ',' +} + +function parseCsv(text: string): CsvRow[] { + const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0) + if (lines.length === 0) return [] + const delim = detectDelimiter(lines[0]) + const headers = lines[0].split(delim).map((h) => h.trim().toLowerCase()) + const idx = { + name: headers.indexOf('name'), + price: headers.indexOf('price'), + unit: headers.indexOf('unit'), + group: headers.indexOf('group'), + barcode: headers.indexOf('barcode'), + } + if (idx.name === -1) { + return [{ + rowIndex: 0, name: '', price: null, unit: null, group: null, barcode: null, + errors: ['Не найдена колонка "name" в заголовке.'], + }] + } + const rows: CsvRow[] = [] + for (let i = 1; i < lines.length; i++) { + const cells = lines[i].split(delim).map((c) => c.trim()) + const name = cells[idx.name] ?? '' + const priceRaw = idx.price >= 0 ? cells[idx.price] ?? '' : '' + const price = priceRaw ? Number(priceRaw.replace(',', '.')) : null + const unit = idx.unit >= 0 ? cells[idx.unit] ?? '' : '' + const group = idx.group >= 0 ? cells[idx.group] ?? '' : '' + const barcode = idx.barcode >= 0 ? cells[idx.barcode] ?? '' : '' + const errors: string[] = [] + if (!name) errors.push('Пустое имя') + if (priceRaw && (!Number.isFinite(price) || (price ?? 0) < 0)) + errors.push(`Некорректная цена «${priceRaw}»`) + rows.push({ + rowIndex: i, name, price: Number.isFinite(price) ? price : null, + unit: unit || null, group: group || null, barcode: barcode || null, errors, + }) + } + return rows +} + +export function ProductsCsvImport({ open, onClose, onSuccess }: ProductsCsvImportProps) { + const [rows, setRows] = useState([]) + const [fileName, setFileName] = useState('') + const fileInputRef = useRef(null) + + const importMutation = useMutation({ + mutationFn: async () => { + const payload = { + rows: rows.map((r) => ({ + name: r.name, price: r.price, unitCode: r.unit, groupName: r.group, barcode: r.barcode, + })), + autoCreateGroup: true, + } + const res = await api.post('/api/catalog/products/import-csv', payload) + return res.data + }, + onSuccess: (data) => { + if (data.errors.length > 0) { + toast.error(`Импорт прерван: ${data.errors.length} ошибок. Транзакция откачена.`) + } else { + toast.success(`Импортировано: ${data.created}`) + onSuccess() + onClose() + setRows([]) + setFileName('') + } + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error + ?? 'Не удалось импортировать CSV' + toast.error(msg) + }, + }) + + if (!open) return null + + const handleFile = async (file: File) => { + setFileName(file.name) + const text = await file.text() + const parsed = parseCsv(text) + setRows(parsed) + } + + const hasErrors = rows.some((r) => r.errors.length > 0) + const validCount = rows.filter((r) => r.errors.length === 0).length + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+

Импорт товаров из CSV

+ +
+ +
+

+ Колонки в заголовке (регистр не важен): name,price,unit,group,barcode. + Разделитель — запятая или точка с запятой. Группы создаются автоматически, если их нет. +

+
+ { const f = e.target.files?.[0]; if (f) handleFile(f) }} + className="hidden" + /> + + {fileName && ( + + {fileName} — {rows.length} строк + + )} +
+
+ +
+ {rows.length === 0 ? ( +

+ Выберите CSV-файл, чтобы увидеть превью. +

+ ) : ( + + + + + + + + + + + + + + {rows.slice(0, 200).map((r) => ( + 0 ? 'bg-red-50 dark:bg-red-900/20' : ''}> + + + + + + + + + ))} + {rows.length > 200 && ( + + + + )} + +
#НазваниеЦенаЕд.ГруппаШтрихкод
{r.rowIndex}{r.name || пусто}{r.price ?? '—'}{r.unit ?? '—'}{r.group ?? '—'}{r.barcode ?? '—'} + {r.errors.length > 0 + ? + : } +
+ … показаны первые 200 строк. При импорте обработаются все. +
+ )} +
+ +
+ {rows.length > 0 && ( + + Корректных: {validCount} + {hasErrors && <> · с ошибками: {rows.length - validCount}} + + )} +
+ + +
+
+
+
+ ) +} diff --git a/src/food-market.web/src/components/QuickActionsPalette.tsx b/src/food-market.web/src/components/QuickActionsPalette.tsx new file mode 100644 index 0000000..fce2ba3 --- /dev/null +++ b/src/food-market.web/src/components/QuickActionsPalette.tsx @@ -0,0 +1,211 @@ +/** + * Sprint 19: палитра «быстрых действий» (Cmd+J). + * + * Отдельно от Cmd+K (там глобальный поиск + навигация по разделам). Здесь — + * фиксированный список действий пользователя + история его недавно + * использованных действий, чтобы повторяющиеся операции были «в один клик». + * + * Список доступных действий — статический (хардкод), фильтруется по ролям + * (пока — без фильтра, MVP). История — в localStorage `fm.quickActions.recent` + * (массив id, последнее использованное первое, max 10). + * + * Открывается: + * - Cmd+J / Ctrl+J — глобальный хоткей в AppLayout + * - props.open / props.onClose — контроль из родителя + * + * Закрывается: + * - клик по действию (после вызова action.onSelect) + * - Esc / click-outside + */ +import { useEffect, useState, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Plus, ShoppingCart, BarChart3, TruckIcon, Users, Boxes, + PackagePlus, PackageMinus, ArrowRightLeft, Tag, Archive, ClipboardCheck, +} from 'lucide-react' + +interface QuickAction { + id: string + label: string + hint?: string + icon: React.ComponentType<{ className?: string }> + /** Вызывается при выборе; навигация чаще всего. */ + run: (navigate: ReturnType) => void +} + +// Список действий — добавляются sparingly, иначе палитра превращается в Cmd+K. +const ACTIONS: QuickAction[] = [ + { id: 'product-new', label: 'Создать товар', icon: Plus, run: (n) => n('/catalog/products/new') }, + { id: 'sale-new', label: 'Провести продажу', icon: ShoppingCart, run: (n) => n('/sales/retail/new') }, + { id: 'supply-new', label: 'Создать приёмку', icon: TruckIcon, run: (n) => n('/purchases/supplies/new') }, + { id: 'counterparty-new', label: 'Добавить контрагента', icon: Users, run: (n) => n('/catalog/counterparties/new') }, + { id: 'enter-new', label: 'Оприходование', icon: PackagePlus, run: (n) => n('/inventory/enters/new') }, + { id: 'loss-new', label: 'Списание', icon: PackageMinus, run: (n) => n('/inventory/losses/new') }, + { id: 'transfer-new', label: 'Перемещение', icon: ArrowRightLeft, run: (n) => n('/inventory/transfers/new') }, + { id: 'inventory-new', label: 'Инвентаризация', icon: ClipboardCheck, run: (n) => n('/inventory/inventories/new') }, + { id: 'report-sales', label: 'Отчёт «Продажи»', icon: BarChart3, run: (n) => n('/reports/sales') }, + { id: 'report-stock', label: 'Отчёт «Остатки»', icon: Boxes, run: (n) => n('/reports/stock') }, + { id: 'report-profit', label: 'Отчёт «Прибыль»', icon: BarChart3, run: (n) => n('/reports/profit') }, + { id: 'report-abc', label: 'Отчёт ABC', icon: BarChart3, run: (n) => n('/reports/abc') }, + { id: 'promotions', label: 'Акции и скидки', icon: Tag, run: (n) => n('/promotions') }, + { id: 'products-archive', label: 'Архив товаров', icon: Archive, run: (n) => n('/catalog/products?archived=true') }, +] + +const STORAGE_KEY = 'fm.quickActions.recent' + +function loadRecent(): string[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const arr = JSON.parse(raw) as unknown + if (!Array.isArray(arr)) return [] + return arr.filter((x) => typeof x === 'string').slice(0, 10) + } catch { return [] } +} + +function pushRecent(id: string) { + const cur = loadRecent().filter((x) => x !== id) + const next = [id, ...cur].slice(0, 10) + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* no-op */ } +} + +interface QuickActionsPaletteProps { + open: boolean + onClose: () => void +} + +export function QuickActionsPalette({ open, onClose }: QuickActionsPaletteProps) { + const navigate = useNavigate() + const [query, setQuery] = useState('') + const [highlight, setHighlight] = useState(0) + const inputRef = useRef(null) + + // Fresh recents — пересчитываем при каждом открытии. + const [recents, setRecents] = useState([]) + + useEffect(() => { + if (open) { + setQuery('') + setHighlight(0) + setRecents(loadRecent()) + // input requires DOM mount — defer focus by one tick. + setTimeout(() => inputRef.current?.focus(), 0) + } + }, [open]) + + useEffect(() => { + if (!open) return + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open, onClose]) + + if (!open) return null + + // Сортировка: при пустом query — recent first, потом всё остальное; + // при query — fuzzy подстрочное совпадение по label. + const q = query.trim().toLowerCase() + let items: QuickAction[] + if (q) { + items = ACTIONS.filter((a) => a.label.toLowerCase().includes(q)) + } else if (recents.length > 0) { + const recentMap = new Map(ACTIONS.map((a) => [a.id, a])) + const recentItems = recents.map((id) => recentMap.get(id)).filter(Boolean) as QuickAction[] + const rest = ACTIONS.filter((a) => !recents.includes(a.id)) + items = [...recentItems, ...rest] + } else { + items = ACTIONS + } + + const handleSelect = (a: QuickAction) => { + pushRecent(a.id) + onClose() + // navigate after onClose so palette unmounts cleanly. + setTimeout(() => a.run(navigate), 0) + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+ { setQuery(e.target.value); setHighlight(0) }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, items.length - 1)) } + if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)) } + if (e.key === 'Enter') { + e.preventDefault() + const sel = items[highlight] + if (sel) handleSelect(sel) + } + }} + placeholder="Быстрое действие… (Cmd+J)" + className="w-full bg-transparent outline-none text-sm placeholder:text-slate-400" + /> +
+
+ {items.length === 0 ? ( +
+ Ничего не найдено. +
+ ) : ( +
    + {!q && recents.length > 0 && ( +
  • + Недавние +
  • + )} + {items.map((a, i) => { + const Icon = a.icon + const isRecent = !q && recents.includes(a.id) && i < recents.length + const isFirstNonRecent = !q && recents.length > 0 && i === recents.length + return ( +
  • + {isFirstNonRecent && ( +
    + Все действия +
    + )} + +
  • + ) + })} +
+ )} +
+
+ ↑↓ навигация · Enter выполнить · Esc закрыть + Cmd+J +
+
+
+ ) +} diff --git a/src/food-market.web/src/components/SavedPresets.tsx b/src/food-market.web/src/components/SavedPresets.tsx new file mode 100644 index 0000000..473b818 --- /dev/null +++ b/src/food-market.web/src/components/SavedPresets.tsx @@ -0,0 +1,158 @@ +/** + * Sprint 19: chips сохранённых пресетов фильтров. + * + * Хост-страница даёт pageKey (стабильный slug), текущий снимок фильтров + * (object) и обработчик применения. Компонент: + * - грузит /api/user/presets?pageKey=… через TanStack Query + * - рендерит chips сверху списка + * - «Сохранить текущие» открывает inline-prompt с именем + * - клик по chip'у → onApply(JSON.parse(configJson)) + * - X на chip'е удаляет пресет (confirm-toast) + * + * Per-user изоляция — серверная (controller фильтрует по userId). Здесь UX. + */ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Bookmark, BookmarkPlus, X } from 'lucide-react' +import { api } from '@/lib/api' +import { toast } from '@/lib/toast' + +export interface SavedPresetsProps { + /** Стабильный идентификатор страницы — должен совпадать на сервере. */ + pageKey: string + /** Текущее состояние фильтров для сохранения. */ + currentConfig: T + /** Колбэк применения пресета — должен заменить состояние фильтров на parsedConfig. */ + onApply: (config: T) => void +} + +interface PresetDto { + id: string + pageKey: string + name: string + configJson: string + updatedAt: string +} + +export function SavedPresets({ pageKey, currentConfig, onApply }: SavedPresetsProps) { + const qc = useQueryClient() + const [savingName, setSavingName] = useState(null) + + const list = useQuery({ + queryKey: ['/api/user/presets', pageKey], + queryFn: async () => (await api.get(`/api/user/presets?pageKey=${encodeURIComponent(pageKey)}`)).data, + staleTime: 5 * 60 * 1000, + }) + + const create = useMutation({ + mutationFn: async (name: string) => { + const res = await api.post('/api/user/presets', { + pageKey, name, configJson: JSON.stringify(currentConfig), + }) + return res.data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/user/presets', pageKey] }) + toast.success('Пресет сохранён') + setSavingName(null) + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error + ?? 'Не удалось сохранить пресет' + toast.error(msg) + }, + }) + + const remove = useMutation({ + mutationFn: async (id: string) => { + await api.delete(`/api/user/presets/${id}`) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/user/presets', pageKey] }), + }) + + const apply = (p: PresetDto) => { + try { + const cfg = JSON.parse(p.configJson) as T + onApply(cfg) + } catch { + toast.error('Пресет повреждён — попробуйте сохранить заново') + } + } + + const presets = list.data ?? [] + + return ( +
+ {presets.map((p) => ( + + + + + ))} + + {savingName === null ? ( + + ) : ( +
{ + e.preventDefault() + const name = savingName.trim() + if (!name) { toast.error('Введите имя'); return } + create.mutate(name) + }} + className="inline-flex items-center gap-1" + > + setSavingName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') setSavingName(null) }} + placeholder="Имя пресета" + className="h-7 px-2 text-xs border border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 rounded" + /> + + +
+ )} +
+ ) +} diff --git a/src/food-market.web/src/help/keyboard-shortcuts.md b/src/food-market.web/src/help/keyboard-shortcuts.md new file mode 100644 index 0000000..a4dd4a0 --- /dev/null +++ b/src/food-market.web/src/help/keyboard-shortcuts.md @@ -0,0 +1,60 @@ +--- +title: Горячие клавиши и быстрая работа +group: power-user +order: 1 +--- + +# Горячие клавиши + +Все хоткеи работают глобально, кроме случаев когда фокус в инпуте/textarea. + +## Глобальные + +| Клавиши | Действие | +|---|---| +| `Cmd+K` / `Ctrl+K` | Командная палитра — глобальный поиск + навигация по разделам | +| `Cmd+J` / `Ctrl+J` | Быстрые действия — список частых операций с историей «недавнего» | +| `?` | Показать оверлей со всеми хоткеями | +| `Esc` | Закрыть открытое модальное окно / палитру / диалог | + +## Списки и таблицы (Sprint 19) + +В списках товаров, контрагентов, документов: + +| Клавиши | Действие | +|---|---| +| `/` | Фокус в поле поиска | +| `n` | Создать новую запись (новый товар, новый чек и т.п.) | +| `↑` / `↓` | Переместить фокус по строкам | +| `Home` / `End` | Первая / последняя строка | +| `Enter` | Открыть выбранную строку | +| `Space` | Выбрать строку (для bulk-операций) | +| `Delete` | Удалить строку (с подтверждением) | + +Подсвеченная зелёным строка — текущая «keyboard-focused». `Space` добавляет/убирает её в выделение; внизу появляется панель массовых операций. + +## Inline-edit + +На странице **Каталог → Товары** двойной клик по ячейке цены превращает её в редактируемое поле. `Enter` сохраняет, `Esc` отменяет. Изменение применяется сразу (optimistic), при ошибке — откат + toast. + +## Bulk-операции + +Выберите 1+ товаров через checkbox или `Space`. Появится панель внизу: + +- **Изменить цену** — +%/+₸ к выбранным (с округлением по настройкам организации). +- **Сменить группу** — массовый перенос. +- **Архивировать** — скрыть из обычных списков (история остаётся). +- **Снять с продажи** — товар не предлагается в новых документах. + +Все операции — одной транзакцией; при ошибке откатывается всё. + +## Пресеты фильтров + +В отчётах (Продажи / Остатки / Прибыль) и каталоге товаров сверху списка появляются chips сохранённых наборов фильтров. Кнопка **«Сохранить как пресет»** запоминает текущие фильтры под именем. Клик по chip'у — применить; X — удалить. + +Пресеты per-user внутри организации (другие сотрудники не видят). + +## Импорт / Экспорт + +- **Импорт CSV** товаров: кнопка «Импорт CSV» на каталоге. Колонки: `name,price,unit,group,barcode`. Разделитель — `,` или `;`. Превью с подсветкой ошибок, коммит транзакцией (всё или ничего). +- **Экспорт CSV/XLSX**: кнопка «Экспорт» в списках товаров, контрагентов, чеков, приёмок, остатков. Файл готовится на сервере с теми же фильтрами что в списке. diff --git a/src/food-market.web/src/lib/help-topics.ts b/src/food-market.web/src/lib/help-topics.ts index 6015f48..ce566a3 100644 --- a/src/food-market.web/src/lib/help-topics.ts +++ b/src/food-market.web/src/lib/help-topics.ts @@ -79,6 +79,16 @@ export const HELP_TOPICS: Record = { title: 'Сотрудник', short: 'Создаём запись сотрудника без аккаунта пользователя. Чтобы он мог войти — отправьте приглашение по email из «Настройки → Сотрудники».', }, + 'keyboard-shortcuts': { + title: 'Горячие клавиши и bulk-операции', + short: 'Cmd+K — поиск, Cmd+J — быстрые действия. В таблицах — стрелки/Enter/Space/Delete. Bulk-операции по checkbox-выбору. Пресеты фильтров chips сверху.', + fullPath: 'keyboard-shortcuts', + }, + 'csv-import': { + title: 'Импорт CSV', + short: 'Колонки: name,price,unit,group,barcode. Разделитель «,» или «;». Группы создаются автоматически. Транзакция: всё или ничего.', + fullPath: 'keyboard-shortcuts#csv', + }, } /** Возвращает topic-meta или fallback с переданным текстом. */ diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 00c9027..6c5cfa8 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -64,6 +64,8 @@ export interface Product { purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null; cost: number; lastSupplyAt: string | null; imageUrl: string | null; + // Sprint 19: bulk-флаги. + isArchived: boolean; isAvailableForSale: boolean; prices: ProductPrice[]; barcodes: ProductBarcode[] } diff --git a/src/food-market.web/src/pages/CounterpartiesPage.tsx b/src/food-market.web/src/pages/CounterpartiesPage.tsx index a150aad..2caed52 100644 --- a/src/food-market.web/src/pages/CounterpartiesPage.tsx +++ b/src/food-market.web/src/pages/CounterpartiesPage.tsx @@ -5,6 +5,7 @@ import { useShortcuts } from '@/lib/useShortcuts' import { Plus, Trash2, Users } from 'lucide-react' import { api } from '@/lib/api' import { ListPageShell } from '@/components/ListPageShell' +import { ExportButton } from '@/components/ExportButton' import { DataTable } from '@/components/DataTable' import { EmptyState } from '@/components/EmptyState' import { Pagination } from '@/components/Pagination' @@ -89,6 +90,7 @@ export function CounterpartiesPage() { actions={ <> + } diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index 024bf83..3a5c402 100644 --- a/src/food-market.web/src/pages/ProductsPage.tsx +++ b/src/food-market.web/src/pages/ProductsPage.tsx @@ -5,13 +5,19 @@ import { EmptyState } from '@/components/EmptyState' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' import { Button } from '@/components/Button' -import { Plus, Filter, X, FolderTree, Package } from 'lucide-react' +import { Plus, Filter, X, FolderTree, Package, Upload } from 'lucide-react' import { useCatalogList } from '@/lib/useCatalog' import { useShortcuts } from '@/lib/useShortcuts' import { useOrgSettings } from '@/lib/useOrgSettings' import { usePriceTypes } from '@/lib/useLookups' import { ProductGroupTree } from '@/components/ProductGroupTree' import { MoneyInput } from '@/components/Field' +import { ProductsBulkBar } from '@/components/ProductsBulkBar' +import { SavedPresets } from '@/components/SavedPresets' +import { InlinePriceCell } from '@/components/InlinePriceCell' +import { ProductsCsvImport } from '@/components/ProductsCsvImport' +import { ExportButton } from '@/components/ExportButton' +import { useQueryClient } from '@tanstack/react-query' import { packagingLabel, type Product } from '@/lib/types' const URL = '/api/catalog/products' @@ -100,6 +106,12 @@ export function ProductsPage() { const navigate = useNavigate() const [filters, setFilters] = useState(defaultFilters) const [filtersOpen, setFiltersOpen] = useState(false) + // Sprint 19: bulk-selection. Set хранит выбранные id; при смене фильтра/ + // страницы сбрасываем (чтобы не оперировать «забытыми» товарами). + const [selectedIds, setSelectedIds] = useState>(new Set()) + const clearSelection = () => setSelectedIds(new Set()) + const [csvOpen, setCsvOpen] = useState(false) + const qc = useQueryClient() const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL, toExtra(filters)) const org = useOrgSettings() const priceTypes = usePriceTypes() @@ -118,17 +130,67 @@ export function ProductsPage() { }) type Col = { - header: string + header: React.ReactNode width?: string className?: string sortKey?: string cell: (r: Product) => React.ReactNode } + // Checkbox-колонка для bulk-selection. Header — «select all visible». + // Клик по checkbox не должен открывать товар → stopPropagation. + const visibleIds = (data?.items ?? []).map((p) => p.id) + const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id)) + const someVisibleSelected = visibleIds.some((id) => selectedIds.has(id)) + const toggleAllVisible = () => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (allVisibleSelected) visibleIds.forEach((id) => next.delete(id)) + else visibleIds.forEach((id) => next.add(id)) + return next + }) + } + const toggleOne = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + const baseColumns: Col[] = [ + { + header: ( + { if (el) el.indeterminate = !allVisibleSelected && someVisibleSelected }} + onChange={toggleAllVisible} + aria-label="Выбрать все видимые товары" + className="w-4 h-4 accent-emerald-600 cursor-pointer align-middle" + /> + ), + width: '42px', + cell: (r) => ( + toggleOne(r.id)} + onClick={(e) => e.stopPropagation()} + aria-label={`Выбрать товар ${r.name}`} + className="w-4 h-4 accent-emerald-600 cursor-pointer" + /> + ), + }, { header: 'Название', sortKey: 'name', cell: (r) => (
{r.name}
{r.article &&
{r.article}
} + {(r.isArchived || !r.isAvailableForSale) && ( +
+ {r.isArchived && архив} + {!r.isAvailableForSale && не в продаже} +
+ )}
)}, { header: 'Фасовка', width: '110px', sortKey: 'packaging', cell: (r) => packagingLabel[r.packaging] ?? '—' }, @@ -162,13 +224,28 @@ export function ProductsPage() { sortKey: 'systemPrice', cell: (r) => { const pr = systemPriceType ? r.prices?.find(x => x.priceTypeId === systemPriceType.id) : undefined - if (!pr) return '—' const fractional = org.data?.allowFractionalPrices ?? false - const num = pr.amount.toLocaleString('ru', - fractional - ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } - : { maximumFractionDigits: 0 }) - return `${num} ${pr.currencyCode ?? ''}`.trim() + const formatAmount = (n: number | null) => { + if (n == null) return '—' + const num = n.toLocaleString('ru', + fractional + ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } + : { maximumFractionDigits: 0 }) + return `${num} ${pr?.currencyCode ?? org.data?.defaultCurrencySymbol ?? ''}`.trim() + } + // Sprint 19: dblclick → inline-edit. priceTypeId=systemPriceType.id; + // если PriceType ещё нет в БД (priceTypes только подгружаются) + // — fallback на текст без edit'a. + if (!systemPriceType) return formatAmount(pr?.amount ?? null) + return ( + + ) }, }, ] @@ -228,12 +305,36 @@ export function ProductsPage() { > Фильтры{activeCount > 0 ? ` (${activeCount})` : ''} + + + setCsvOpen(false)} + onSuccess={() => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })} + /> + + {/* Sprint 19: SavedPresets chip-bar — открыт всегда, не зависит от + filtersOpen, потому что чипсы — основной point of access. */} +
+ { setFilters(cfg as Filters); setPage(1) }} + /> +
+ {/* Filter panel */} {filtersOpen && (
@@ -308,6 +409,8 @@ export function ProductsPage() { sortOrder={sortOrder} onSortChange={setSort} onRowClick={(r) => navigate(`/catalog/products/${r.id}`)} + keyboardNav + onSelect={(r) => toggleOne(r.id)} columns={baseColumns} empty="Товаров ещё нет. Они появятся после приёмки или через API." /> @@ -319,6 +422,15 @@ export function ProductsPage() {
)} + + {/* Sprint 19: sticky bulk-bar. Появляется когда выбран ≥1 товар. + Не сбрасывает выбор при смене страницы — пользователь может выбрать + на странице 1, перейти на 2, добавить ещё, и применить операцию ко + всем разом. Сбрасывает только onClear() из самой панели. */} + ) diff --git a/src/food-market.web/src/pages/ProfitReportPage.tsx b/src/food-market.web/src/pages/ProfitReportPage.tsx index 7421e87..27e67b5 100644 --- a/src/food-market.web/src/pages/ProfitReportPage.tsx +++ b/src/food-market.web/src/pages/ProfitReportPage.tsx @@ -8,6 +8,7 @@ import { Field, Select } from '@/components/Field' import { DateField } from '@/components/DateField' import { useStores, useProductGroups } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' +import { SavedPresets } from '@/components/SavedPresets' import { type ProfitReportRow } from '@/lib/types' const GROUPS = [ @@ -88,6 +89,16 @@ export function ProfitReportPage() {
+
+ { + setFrom(cfg.from); setTo(cfg.to); setGroupBy(cfg.groupBy) + setStoreId(cfg.storeId); setProductGroupId(cfg.productGroupId) + }} + /> +
setFrom(v ?? todayIso())} /> diff --git a/src/food-market.web/src/pages/RetailSalesPage.tsx b/src/food-market.web/src/pages/RetailSalesPage.tsx index d33aac9..9dfb756 100644 --- a/src/food-market.web/src/pages/RetailSalesPage.tsx +++ b/src/food-market.web/src/pages/RetailSalesPage.tsx @@ -2,6 +2,7 @@ import { useRef } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Plus, Receipt } from 'lucide-react' import { ListPageShell } from '@/components/ListPageShell' +import { ExportButton } from '@/components/ExportButton' import { DataTable } from '@/components/DataTable' import { EmptyState } from '@/components/EmptyState' import { Pagination } from '@/components/Pagination' @@ -45,6 +46,7 @@ export function RetailSalesPage() { actions={ <> + diff --git a/src/food-market.web/src/pages/SalesReportPage.tsx b/src/food-market.web/src/pages/SalesReportPage.tsx index 6c69ca9..288770d 100644 --- a/src/food-market.web/src/pages/SalesReportPage.tsx +++ b/src/food-market.web/src/pages/SalesReportPage.tsx @@ -8,6 +8,7 @@ import { Field, Select } from '@/components/Field' import { DateField } from '@/components/DateField' import { useStores, useProductGroups } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' +import { SavedPresets } from '@/components/SavedPresets' import { type SalesReportRow } from '@/lib/types' const GROUPS = [ @@ -90,6 +91,17 @@ export function SalesReportPage() {
+ {/* Sprint 19: SavedPresets — chips сохранённых наборов фильтров. */} +
+ { + setFrom(cfg.from); setTo(cfg.to); setGroupBy(cfg.groupBy) + setStoreId(cfg.storeId); setProductGroupId(cfg.productGroupId) + }} + /> +
setFrom(v ?? todayIso())} /> diff --git a/src/food-market.web/src/pages/StockPage.tsx b/src/food-market.web/src/pages/StockPage.tsx index da968fb..9758fd5 100644 --- a/src/food-market.web/src/pages/StockPage.tsx +++ b/src/food-market.web/src/pages/StockPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { ListPageShell } from '@/components/ListPageShell' +import { ExportButton } from '@/components/ExportButton' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' @@ -47,6 +48,7 @@ export function StockPage() {
{ setIncludeZero(v); setPage(1) }} /> { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" /> +
} footer={data && data.total > 0 && ( diff --git a/src/food-market.web/src/pages/StockReportPage.tsx b/src/food-market.web/src/pages/StockReportPage.tsx index 8897b7b..aaed2ac 100644 --- a/src/food-market.web/src/pages/StockReportPage.tsx +++ b/src/food-market.web/src/pages/StockReportPage.tsx @@ -8,6 +8,7 @@ import { Field, Select, Checkbox } from '@/components/Field' import { DateField } from '@/components/DateField' import { useStores, useProductGroups } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' +import { SavedPresets } from '@/components/SavedPresets' import { type StockReportRow } from '@/lib/types' /** Отчёт «Остатки на дату». Реконструкция через журнал StockMovement. */ @@ -72,6 +73,16 @@ export function StockReportPage() {
+
+ { + setDate(cfg.date); setStoreId(cfg.storeId) + setProductGroupId(cfg.productGroupId); setIncludeZero(cfg.includeZero) + }} + /> +
setDate(v ?? today)} /> diff --git a/src/food-market.web/src/pages/SuppliesPage.tsx b/src/food-market.web/src/pages/SuppliesPage.tsx index a7ed70d..495ab44 100644 --- a/src/food-market.web/src/pages/SuppliesPage.tsx +++ b/src/food-market.web/src/pages/SuppliesPage.tsx @@ -2,6 +2,7 @@ import { useRef } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Plus, PackagePlus } from 'lucide-react' import { ListPageShell } from '@/components/ListPageShell' +import { ExportButton } from '@/components/ExportButton' import { DataTable } from '@/components/DataTable' import { EmptyState } from '@/components/EmptyState' import { Pagination } from '@/components/Pagination' @@ -37,6 +38,7 @@ export function SuppliesPage() { actions={ <> +