feat(s19): bulk-операции + presets + power-user UX (7 пунктов)
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
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.
<SavedPresets> компонент с 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. <InlinePriceCell> с dblclick → input, optimistic update
+ revert при ошибке.
5. CSV import товаров — POST /api/catalog/products/import-csv (rows[]).
Клиент парсит CSV (auto-detect разделитель ,/;), сервер commit'ит
транзакцией. autoCreateGroup для новых групп. <ProductsCsvImport>
модалка с preview и подсветкой ошибочных строк.
6. CSV/XLSX export — endpoint'ы /export на 5 контроллерах (products,
counterparties, stock, retail-sales, supplies). Reuse существующего
ReportExport.Csv/Xlsx. <ExportButton> 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 <noreply@anthropic.com>
This commit is contained in:
parent
00f248a460
commit
6940aa40df
48
docs/sprint19-progress.md
Normal file
48
docs/sprint19-progress.md
Normal file
|
|
@ -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). `<SavedPresets>` 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.
|
||||
|
|
@ -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<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: экспорт списка контрагентов.</summary>
|
||||
[HttpGet("export")]
|
||||
public async Task<IActionResult> 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<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<decimal> 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<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
return new PagedResult<ProductDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: экспорт списка товаров с теми же фильтрами что
|
||||
/// и /api/catalog/products. Сервер-side генерация CSV/XLSX через
|
||||
/// <see cref="ReportExport"/>. Tenant-isolation — стандартный query-filter.</summary>
|
||||
[HttpGet("export")]
|
||||
public async Task<IActionResult> 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<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
@ -348,6 +410,62 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
|||
return Ok(new { retail = newRetail });
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: inline-edit цены отдельным эндпоинтом. UI делает
|
||||
/// dblclick на ячейке системной розничной цены → input → Enter → этот
|
||||
/// PATCH. Если priceTypeId не указан — используется системная розничная.</summary>
|
||||
public record UpdatePriceRequest(Guid? PriceTypeId, decimal Amount);
|
||||
|
||||
[HttpPatch("{id:guid}/price"), RequiresPermission("ProductsEdit")]
|
||||
public async Task<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
@ -358,6 +476,359 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>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 } — число
|
||||
/// реально обновлённых строк.</summary>
|
||||
public record BulkUpdateRequest(
|
||||
IReadOnlyList<Guid> Ids,
|
||||
string Op,
|
||||
Dictionary<string, object>? Params);
|
||||
public record BulkUpdateResponse(int Affected);
|
||||
|
||||
/// <summary>Sprint 19: bulk-импорт товаров из CSV (фронт парсит, шлёт сюда).
|
||||
/// Один запрос — одна транзакция. Колонки на входе: name (required), price
|
||||
/// (для системной розничной), unitCode (kg/l/m/упак), groupName (создаётся
|
||||
/// если не существует), barcode (первый штрихкод). Возвращает summary +
|
||||
/// список созданных id.
|
||||
///
|
||||
/// Валидация: пустое имя → 400. Группа не найдена и autoCreateGroup=false
|
||||
/// → 400 с указанием row. Barcode-конфликт по уникальному индексу → 400
|
||||
/// с указанием row. Всё или ничего: при ошибке откатываем транзакцию.</summary>
|
||||
public record CsvProductRow(
|
||||
string Name, decimal? Price, string? UnitCode,
|
||||
string? GroupName, string? Barcode);
|
||||
public record CsvImportRequest(IReadOnlyList<CsvProductRow> Rows, bool AutoCreateGroup = true);
|
||||
public record CsvImportRowError(int Row, string Error);
|
||||
public record CsvImportResponse(int Created, IReadOnlyList<CsvImportRowError> Errors, IReadOnlyList<Guid> Ids);
|
||||
|
||||
[HttpPost("import-csv"), RequiresPermission("ProductsEdit")]
|
||||
public async Task<ActionResult<CsvImportResponse>> 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<CsvImportRowError>();
|
||||
var barcodesInImport = new HashSet<string>(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<string>().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<Guid>();
|
||||
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<ActionResult<BulkUpdateResponse>> 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<string, object>();
|
||||
|
||||
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<string, object> 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<string, object> 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<string, object> 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<string, object> 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<DuplicateProductRef> Products);
|
||||
public record DuplicateProductRef(Guid ProductId, string ProductName, string? Article);
|
||||
|
||||
|
|
@ -501,6 +972,7 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> 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());
|
||||
|
||||
|
|
|
|||
120
src/food-market.api/Controllers/Common/UserPresetsController.cs
Normal file
120
src/food-market.api/Controllers/Common/UserPresetsController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>Sprint 19: CRUD сохранённых пользовательских пресетов фильтров.
|
||||
/// Per-user внутри org: пользователь видит/правит только свои пресеты.
|
||||
/// Tenant-isolation — стандартный query-filter; per-user — здесь на уровне
|
||||
/// контроллера через WHERE UserId = currentUserId.</summary>
|
||||
[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<ActionResult<IReadOnlyList<PresetDto>>> 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<ActionResult<PresetDto>> 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<ActionResult<PresetDto>> 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<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<StockRow> { Items = items, Total = total, Page = page, PageSize = pageSize };
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: экспорт остатков.</summary>
|
||||
[HttpGet("stock/export")]
|
||||
public async Task<IActionResult> 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,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,36 @@ public record SupplyInput(
|
|||
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: экспорт списка приёмок с теми же фильтрами.</summary>
|
||||
[HttpGet("export")]
|
||||
public async Task<IActionResult> 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<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -237,6 +237,37 @@ public record SalesStatsResponse(
|
|||
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
/// <summary>Sprint 19: экспорт списка чеков с фильтрами status/storeId/from/to.</summary>
|
||||
[HttpGet("export")]
|
||||
public async Task<IActionResult> 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<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<ProductPriceDto> Prices,
|
||||
IReadOnlyList<ProductBarcodeDto> Barcodes);
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,18 @@ public class Product : TenantEntity
|
|||
|
||||
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
||||
|
||||
/// <summary>Архивный товар. Скрывается из обычных списков (List default
|
||||
/// filter); попадает в отчёты по историческим данным, но не предлагается
|
||||
/// в новых документах. Sprint 19: bulk-операция «Архивировать».</summary>
|
||||
public bool IsArchived { get; set; }
|
||||
|
||||
/// <summary>Доступен ли товар для продажи на кассе/в опт. отгрузках.
|
||||
/// Default true. Sprint 19: bulk-операция «Снять с продажи» ставит false;
|
||||
/// POS позже добавит фильтр по этому флагу. Эта галка ОТЛИЧАЕТСЯ от
|
||||
/// IsArchived: «снят с продажи» — временно (например, сезонный товар
|
||||
/// вне сезона), архивный — навсегда (списан).</summary>
|
||||
public bool IsAvailableForSale { get; set; } = true;
|
||||
|
||||
public ICollection<ProductPrice> Prices { get; set; } = [];
|
||||
public ICollection<ProductBarcode> Barcodes { get; set; } = [];
|
||||
public ICollection<ProductImage> Images { get; set; } = [];
|
||||
|
|
|
|||
24
src/food-market.domain/Common/UserPreset.cs
Normal file
24
src/food-market.domain/Common/UserPreset.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Common;
|
||||
|
||||
/// <summary>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 — НЕ применяется (личные пресеты
|
||||
/// чужих юзеров не показываем даже супер-админу).</summary>
|
||||
public class UserPreset : TenantEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public string PageKey { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
/// <summary>JSON-снимок состояния фильтров. Schema-less по дизайну.</summary>
|
||||
public string ConfigJson { get; set; } = "{}";
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
||||
public DbSet<OrgAuditLog> OrgAuditLogs => Set<OrgAuditLog>();
|
||||
public DbSet<foodmarket.Domain.Integrations.ImportJob> ImportJobs => Set<foodmarket.Domain.Integrations.ImportJob>();
|
||||
public DbSet<UserPreset> UserPresets => Set<UserPreset>();
|
||||
|
||||
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
|
||||
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
|
||||
|
|
@ -153,6 +154,17 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt });
|
||||
});
|
||||
|
||||
builder.Entity<UserPreset>(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<foodmarket.Domain.Integrations.ImportJob>(b =>
|
||||
{
|
||||
b.ToTable("import_jobs");
|
||||
|
|
|
|||
|
|
@ -137,6 +137,10 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> 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<Product> 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<ProductPrice> b)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>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).</summary>
|
||||
[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"";
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Phase19b — таблица user_presets для сохранённых фильтров.
|
||||
/// PageKey — стабильный идентификатор страницы ('products', 'reports/sales');
|
||||
/// ConfigJson — JSON со снимком фильтров (schema-less). Уникальность по
|
||||
/// (OrgId, UserId, PageKey, Name) — нельзя два пресета «Январь» на одной
|
||||
/// странице у одного юзера.</summary>
|
||||
[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;");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<ShortcutsOverlay />
|
||||
{/* Командная палитра (Cmd+K / Ctrl+K) — глобальный поиск + навигация. */}
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
{/* Sprint 19: быстрые действия (Cmd+J) — отдельный список с recents. */}
|
||||
<QuickActionsPalette open={quickOpen} onClose={() => setQuickOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
header: string
|
||||
header: ReactNode
|
||||
cell: (row: T) => ReactNode
|
||||
className?: string
|
||||
width?: string
|
||||
|
|
@ -30,12 +31,73 @@ interface DataTableProps<T> {
|
|||
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<T>({
|
||||
rows, columns, rowKey, onRowClick, onRowDoubleClick, empty, isLoading, scrollable = true,
|
||||
sortKey, sortOrder, onSortChange,
|
||||
keyboardNav = false, onDelete, onSelect,
|
||||
}: DataTableProps<T>) {
|
||||
// Sprint 19: индекс «фокусированной» строки для keyboard-nav. -1 = ничего
|
||||
// не выбрано; при первом ↓ становится 0. Сбрасывается при смене набора
|
||||
// строк. Скролл к выбранной — через scrollIntoView с block:'nearest'.
|
||||
const [focusIdx, setFocusIdx] = useState<number>(-1)
|
||||
const rowRefs = useRef<Array<HTMLTableRowElement | null>>([])
|
||||
|
||||
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<T>({
|
|||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
rows.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
onClick={() => 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) => (
|
||||
|
|
|
|||
102
src/food-market.web/src/components/ExportButton.tsx
Normal file
102
src/food-market.web/src/components/ExportButton.tsx
Normal file
|
|
@ -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<string, string | number | boolean | undefined>
|
||||
/** Имя файла без расширения если сервер не дал 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<HTMLDivElement>(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 (
|
||||
<div className="relative inline-block" ref={ref}>
|
||||
<Button variant={variant} size={size} type="button" onClick={() => setOpen((x) => !x)} disabled={loading}>
|
||||
<Download className="w-4 h-4" /> {loading ? 'Готовлю…' : 'Экспорт'}
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 mt-1 z-20 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded shadow-lg overflow-hidden min-w-[140px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => download('csv')}
|
||||
className="block w-full text-left px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
CSV (Excel-RU)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => download('xlsx')}
|
||||
className="block w-full text-left px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
XLSX (Excel)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
src/food-market.web/src/components/InlinePriceCell.tsx
Normal file
129
src/food-market.web/src/components/InlinePriceCell.tsx
Normal file
|
|
@ -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<string>(initialAmount?.toString() ?? '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const qc = useQueryClient()
|
||||
// Локальная «теневая» сумма — отображается пока optimistic-update висит
|
||||
// в кэше; при ошибке возвращаемся к initialAmount.
|
||||
const [displayedAmount, setDisplayedAmount] = useState<number | null>(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 (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => 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 (
|
||||
<span
|
||||
onDoubleClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation()
|
||||
startEdit()
|
||||
}}
|
||||
title="Двойной клик — редактировать"
|
||||
className="cursor-text inline-block w-full"
|
||||
>
|
||||
{format(displayedAmount)}
|
||||
{mutation.isPending && <span className="text-xs text-slate-400 ml-1" aria-live="polite">…</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
258
src/food-market.web/src/components/ProductsBulkBar.tsx
Normal file
258
src/food-market.web/src/components/ProductsBulkBar.tsx
Normal file
|
|
@ -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<Op | null>(null)
|
||||
const qc = useQueryClient()
|
||||
|
||||
const bulkMutation = useMutation({
|
||||
mutationFn: async (payload: { op: string; params?: Record<string, unknown> }) => {
|
||||
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 && (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Массовые операции"
|
||||
className="sticky bottom-0 left-0 right-0 z-20 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 shadow-lg px-4 sm:px-6 py-2.5 flex items-center gap-3 flex-wrap"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
Выбрано: <strong>{selectedIds.length}</strong>
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setOpenOp({ kind: 'price-adjust' })}>
|
||||
<Tag className="w-4 h-4" /> Изменить цену
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setOpenOp({ kind: 'change-group' })}>
|
||||
<FolderInput className="w-4 h-4" /> Сменить группу
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setOpenOp({ kind: 'archive' })}>
|
||||
<Archive className="w-4 h-4" /> Архивировать
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setOpenOp({ kind: 'unarchive' })}>
|
||||
<ArchiveRestore className="w-4 h-4" /> Восстановить
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setOpenOp({ kind: 'toggle-sale', available: false })}>
|
||||
<Ban className="w-4 h-4" /> Снять с продажи
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="ml-auto text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
|
||||
aria-label="Сбросить выбор"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openOp && (
|
||||
<BulkOpModal
|
||||
op={openOp}
|
||||
count={selectedIds.length}
|
||||
onClose={() => setOpenOp(null)}
|
||||
onSubmit={(payload) => bulkMutation.mutate(payload)}
|
||||
isLoading={bulkMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface BulkOpModalProps {
|
||||
op: Op
|
||||
count: number
|
||||
onClose: () => void
|
||||
onSubmit: (payload: { op: string; params?: Record<string, unknown> }) => 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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="fixed inset-0 z-50 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="bg-white dark:bg-slate-900 rounded-lg shadow-xl w-full max-w-md p-5 space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Закрыть" className="text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Операция применится к <strong>{count}</strong> {count === 1 ? 'товару' : 'товарам'}.
|
||||
</p>
|
||||
|
||||
{op.kind === 'price-adjust' && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Тип цены">
|
||||
<Select
|
||||
value={priceTypeId || systemPriceType?.id || ''}
|
||||
onChange={(e) => setPriceTypeId(e.target.value)}
|
||||
>
|
||||
{(priceTypes.data ?? []).map((pt) => (
|
||||
<option key={pt.id} value={pt.id}>{pt.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Тип изменения">
|
||||
<Select value={mode} onChange={(e) => setMode(e.target.value as 'percent' | 'absolute')}>
|
||||
<option value="percent">Процент (±%)</option>
|
||||
<option value="absolute">Сумма (±)</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={mode === 'percent' ? 'На сколько % изменить (например, 10 = +10%, -5 = -5%)' : 'На сколько изменить (например, 100 = +100, -50 = -50)'}>
|
||||
<input
|
||||
type="number"
|
||||
value={delta}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{op.kind === 'change-group' && (
|
||||
<Field label="Новая группа">
|
||||
<Select value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
||||
<option value="">— выбрать —</option>
|
||||
{(groups.data?.items ?? [])
|
||||
.sort((a, b) => a.path.localeCompare(b.path, 'ru'))
|
||||
.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.path || g.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{(op.kind === 'archive' || op.kind === 'unarchive') && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{op.kind === 'archive'
|
||||
? 'Архивные товары скрываются из обычных списков, но остаются в исторических отчётах и документах.'
|
||||
: 'Товары вернутся в обычные списки и снова будут доступны для документов.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{op.kind === 'toggle-sale' && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Товары не будут предлагаться в новых продажах. Восстановить можно через bulk-операцию «Вернуть в продажу».
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={isLoading}>Применить</Button>
|
||||
<Button variant="secondary" type="button" onClick={onClose} disabled={isLoading}>Отмена</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
src/food-market.web/src/components/ProductsCsvImport.tsx
Normal file
244
src/food-market.web/src/components/ProductsCsvImport.tsx
Normal file
|
|
@ -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<CsvRow[]>([])
|
||||
const [fileName, setFileName] = useState<string>('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<ImportResponse>('/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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Импорт товаров из CSV"
|
||||
className="fixed inset-0 z-50 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Импорт товаров из CSV</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Закрыть" className="text-slate-500 hover:text-slate-800">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 border-b border-slate-200 dark:border-slate-800">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 mb-2">
|
||||
Колонки в заголовке (регистр не важен): <code className="bg-slate-100 dark:bg-slate-800 px-1 rounded">name,price,unit,group,barcode</code>.
|
||||
Разделитель — запятая или точка с запятой. Группы создаются автоматически, если их нет.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f) }}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="w-4 h-4" /> Выбрать файл
|
||||
</Button>
|
||||
{fileName && (
|
||||
<span className="text-sm text-slate-700 dark:text-slate-200 inline-flex items-center gap-1">
|
||||
<FileText className="w-4 h-4" /> {fileName} — {rows.length} строк
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto px-5 py-3">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 py-8 text-center">
|
||||
Выберите CSV-файл, чтобы увидеть превью.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-800">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left w-10">#</th>
|
||||
<th className="px-2 py-1 text-left">Название</th>
|
||||
<th className="px-2 py-1 text-right w-24">Цена</th>
|
||||
<th className="px-2 py-1 text-left w-20">Ед.</th>
|
||||
<th className="px-2 py-1 text-left">Группа</th>
|
||||
<th className="px-2 py-1 text-left w-32">Штрихкод</th>
|
||||
<th className="px-2 py-1 text-left w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 200).map((r) => (
|
||||
<tr key={r.rowIndex} className={r.errors.length > 0 ? 'bg-red-50 dark:bg-red-900/20' : ''}>
|
||||
<td className="px-2 py-1 text-slate-400">{r.rowIndex}</td>
|
||||
<td className="px-2 py-1">{r.name || <span className="text-red-600">пусто</span>}</td>
|
||||
<td className="px-2 py-1 text-right font-mono">{r.price ?? '—'}</td>
|
||||
<td className="px-2 py-1">{r.unit ?? '—'}</td>
|
||||
<td className="px-2 py-1">{r.group ?? '—'}</td>
|
||||
<td className="px-2 py-1 font-mono text-xs">{r.barcode ?? '—'}</td>
|
||||
<td className="px-2 py-1">
|
||||
{r.errors.length > 0
|
||||
? <span title={r.errors.join('; ')}><AlertCircle className="w-4 h-4 text-red-600" aria-label="Ошибки" /></span>
|
||||
: <CheckCircle className="w-4 h-4 text-emerald-600" aria-label="Ок" />}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length > 200 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-2 py-2 text-center text-xs text-slate-500">
|
||||
… показаны первые 200 строк. При импорте обработаются все.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
|
||||
{rows.length > 0 && (
|
||||
<span className="text-sm">
|
||||
Корректных: <strong className="text-emerald-600">{validCount}</strong>
|
||||
{hasErrors && <> · с ошибками: <strong className="text-red-600">{rows.length - validCount}</strong></>}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="secondary" type="button" onClick={onClose}>Отмена</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={rows.length === 0 || hasErrors || importMutation.isPending}
|
||||
title={hasErrors ? 'Исправьте ошибки в CSV и загрузите снова' : ''}
|
||||
>
|
||||
{importMutation.isPending ? 'Импортирую…' : `Импортировать ${validCount} товар(ов)`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
src/food-market.web/src/components/QuickActionsPalette.tsx
Normal file
211
src/food-market.web/src/components/QuickActionsPalette.tsx
Normal file
|
|
@ -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<typeof useNavigate>) => 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<HTMLInputElement>(null)
|
||||
|
||||
// Fresh recents — пересчитываем при каждом открытии.
|
||||
const [recents, setRecents] = useState<string[]>([])
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Быстрые действия"
|
||||
className="fixed inset-0 z-50 bg-slate-900/50 backdrop-blur-sm flex items-start justify-center pt-24 px-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="w-full max-w-md bg-white dark:bg-slate-900 rounded-lg shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-200 dark:border-slate-800">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Ничего не найдено.
|
||||
</div>
|
||||
) : (
|
||||
<ul role="listbox">
|
||||
{!q && recents.length > 0 && (
|
||||
<li className="px-3 py-1 text-[10px] uppercase tracking-wider text-slate-400">
|
||||
Недавние
|
||||
</li>
|
||||
)}
|
||||
{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 (
|
||||
<li key={a.id}>
|
||||
{isFirstNonRecent && (
|
||||
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-slate-400 border-t border-slate-100 dark:border-slate-800 mt-1">
|
||||
Все действия
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === highlight}
|
||||
onClick={() => handleSelect(a)}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
className={
|
||||
'w-full text-left px-3 py-2 flex items-center gap-2 text-sm ' +
|
||||
(i === highlight
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-900 dark:text-emerald-200'
|
||||
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800/50')
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1 truncate">{a.label}</span>
|
||||
{isRecent && (
|
||||
<span className="text-[10px] text-slate-400">недавно</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-slate-200 dark:border-slate-800 text-[10px] text-slate-500 dark:text-slate-400 flex justify-between">
|
||||
<span>↑↓ навигация · Enter выполнить · Esc закрыть</span>
|
||||
<span>Cmd+J</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
src/food-market.web/src/components/SavedPresets.tsx
Normal file
158
src/food-market.web/src/components/SavedPresets.tsx
Normal file
|
|
@ -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<T> {
|
||||
/** Стабильный идентификатор страницы — должен совпадать на сервере. */
|
||||
pageKey: string
|
||||
/** Текущее состояние фильтров для сохранения. */
|
||||
currentConfig: T
|
||||
/** Колбэк применения пресета — должен заменить состояние фильтров на parsedConfig. */
|
||||
onApply: (config: T) => void
|
||||
}
|
||||
|
||||
interface PresetDto {
|
||||
id: string
|
||||
pageKey: string
|
||||
name: string
|
||||
configJson: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function SavedPresets<T>({ pageKey, currentConfig, onApply }: SavedPresetsProps<T>) {
|
||||
const qc = useQueryClient()
|
||||
const [savingName, setSavingName] = useState<string | null>(null)
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['/api/user/presets', pageKey],
|
||||
queryFn: async () => (await api.get<PresetDto[]>(`/api/user/presets?pageKey=${encodeURIComponent(pageKey)}`)).data,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const res = await api.post<PresetDto>('/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 (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{presets.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="inline-flex items-center gap-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 text-xs px-2 py-1 rounded-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => apply(p)}
|
||||
className="inline-flex items-center gap-1 hover:text-emerald-700 dark:hover:text-emerald-400"
|
||||
title={`Применить пресет «${p.name}»`}
|
||||
>
|
||||
<Bookmark className="w-3 h-3" aria-hidden="true" />
|
||||
{p.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Удалить пресет «${p.name}»?`)) remove.mutate(p.id)
|
||||
}}
|
||||
className="text-slate-400 hover:text-red-600"
|
||||
aria-label={`Удалить пресет «${p.name}»`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{savingName === null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSavingName('')}
|
||||
className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400 hover:text-emerald-700 dark:hover:text-emerald-400 px-2 py-1 rounded border border-dashed border-slate-300 dark:border-slate-700"
|
||||
>
|
||||
<BookmarkPlus className="w-3 h-3" /> Сохранить как пресет
|
||||
</button>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const name = savingName.trim()
|
||||
if (!name) { toast.error('Введите имя'); return }
|
||||
create.mutate(name)
|
||||
}}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={savingName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
className="text-xs bg-emerald-600 text-white px-2 py-1 rounded hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSavingName(null)}
|
||||
className="text-xs text-slate-500 px-1"
|
||||
aria-label="Отмена"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
src/food-market.web/src/help/keyboard-shortcuts.md
Normal file
60
src/food-market.web/src/help/keyboard-shortcuts.md
Normal file
|
|
@ -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**: кнопка «Экспорт» в списках товаров, контрагентов, чеков, приёмок, остатков. Файл готовится на сервере с теми же фильтрами что в списке.
|
||||
|
|
@ -79,6 +79,16 @@ export const HELP_TOPICS: Record<string, HelpTopic> = {
|
|||
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 с переданным текстом. */
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<>
|
||||
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="Поиск по имени, БИН, ИИН, телефону…" />
|
||||
<ExportButton url="/api/catalog/counterparties/export" params={{ search }} defaultName="counterparties" />
|
||||
<Button onClick={() => { setForm(blankForm); setFieldErrors({}) }}><Plus className="w-4 h-4" /> Добавить</Button>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Filters>(defaultFilters)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
// Sprint 19: bulk-selection. Set хранит выбранные id; при смене фильтра/
|
||||
// страницы сбрасываем (чтобы не оперировать «забытыми» товарами).
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<Product>(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: (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
ref={(el) => { 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) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(r.id)}
|
||||
onChange={() => 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) => (
|
||||
<div>
|
||||
<div className="font-medium">{r.name}</div>
|
||||
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||
{(r.isArchived || !r.isAvailableForSale) && (
|
||||
<div className="text-[10px] mt-0.5 inline-flex items-center gap-1">
|
||||
{r.isArchived && <span className="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 px-1.5 py-0.5 rounded">архив</span>}
|
||||
{!r.isAvailableForSale && <span className="bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 px-1.5 py-0.5 rounded">не в продаже</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)},
|
||||
{ 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',
|
||||
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 ?? ''}`.trim()
|
||||
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 (
|
||||
<InlinePriceCell
|
||||
productId={r.id}
|
||||
priceTypeId={systemPriceType.id}
|
||||
initialAmount={pr?.amount ?? null}
|
||||
format={formatAmount}
|
||||
ariaLabel={`Цена ${systemPriceType.name} для ${r.name}`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -228,12 +305,36 @@ export function ProductsPage() {
|
|||
>
|
||||
<Filter className="w-4 h-4" /> Фильтры{activeCount > 0 ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setCsvOpen(true)}>
|
||||
<Upload className="w-4 h-4" /> Импорт CSV
|
||||
</Button>
|
||||
<ExportButton
|
||||
url="/api/catalog/products/export"
|
||||
params={{ search, groupId: filters.groupId ?? undefined }}
|
||||
defaultName="products"
|
||||
/>
|
||||
<Link to="/catalog/products/new">
|
||||
<Button><Plus className="w-4 h-4" /> Добавить</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductsCsvImport
|
||||
open={csvOpen}
|
||||
onClose={() => setCsvOpen(false)}
|
||||
onSuccess={() => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })}
|
||||
/>
|
||||
|
||||
{/* Sprint 19: SavedPresets chip-bar — открыт всегда, не зависит от
|
||||
filtersOpen, потому что чипсы — основной point of access. */}
|
||||
<div className="px-4 sm:px-6 py-2 border-b border-slate-200 dark:border-slate-800">
|
||||
<SavedPresets
|
||||
pageKey="products"
|
||||
currentConfig={filters}
|
||||
onApply={(cfg) => { setFilters(cfg as Filters); setPage(1) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{filtersOpen && (
|
||||
<div className="px-4 sm:px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-3 sm:gap-4 items-center">
|
||||
|
|
@ -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() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sprint 19: sticky bulk-bar. Появляется когда выбран ≥1 товар.
|
||||
Не сбрасывает выбор при смене страницы — пользователь может выбрать
|
||||
на странице 1, перейти на 2, добавить ещё, и применить операцию ко
|
||||
всем разом. Сбрасывает только onClear() из самой панели. */}
|
||||
<ProductsBulkBar
|
||||
selectedIds={Array.from(selectedIds)}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4">
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div className="mb-3">
|
||||
<SavedPresets
|
||||
pageKey="reports/profit"
|
||||
currentConfig={{ from, to, groupBy, storeId, productGroupId }}
|
||||
onApply={(cfg) => {
|
||||
setFrom(cfg.from); setTo(cfg.to); setGroupBy(cfg.groupBy)
|
||||
setStoreId(cfg.storeId); setProductGroupId(cfg.productGroupId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-x-4 gap-y-3 items-end">
|
||||
<Field label="От">
|
||||
<DateField value={from} onChange={(v) => setFrom(v ?? todayIso())} />
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<>
|
||||
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру чека…" />
|
||||
<ExportButton url="/api/sales/retail/export" defaultName="retail-sales" />
|
||||
<Link to="/sales/retail/new">
|
||||
<Button><Plus className="w-4 h-4" /> Новый чек</Button>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4">
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
|
||||
{/* Sprint 19: SavedPresets — chips сохранённых наборов фильтров. */}
|
||||
<div className="mb-3">
|
||||
<SavedPresets
|
||||
pageKey="reports/sales"
|
||||
currentConfig={{ from, to, groupBy, storeId, productGroupId }}
|
||||
onApply={(cfg) => {
|
||||
setFrom(cfg.from); setTo(cfg.to); setGroupBy(cfg.groupBy)
|
||||
setStoreId(cfg.storeId); setProductGroupId(cfg.productGroupId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-x-4 gap-y-3 items-end">
|
||||
<Field label="От">
|
||||
<DateField value={from} onChange={(v) => setFrom(v ?? todayIso())} />
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
|
||||
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
|
||||
<ExportButton url="/api/inventory/stock/export" params={{ storeId, search, includeZero }} defaultName="stock" />
|
||||
</div>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4">
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div className="mb-3">
|
||||
<SavedPresets
|
||||
pageKey="reports/stock"
|
||||
currentConfig={{ date, storeId, productGroupId, includeZero }}
|
||||
onApply={(cfg) => {
|
||||
setDate(cfg.date); setStoreId(cfg.storeId)
|
||||
setProductGroupId(cfg.productGroupId); setIncludeZero(cfg.includeZero)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-4 gap-y-3 items-end">
|
||||
<Field label="На дату">
|
||||
<DateField value={date} onChange={(v) => setDate(v ?? today)} />
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<>
|
||||
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
||||
<ExportButton url="/api/purchases/supplies/export" defaultName="supplies" />
|
||||
<Link to="/purchases/supplies/new">
|
||||
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
|
||||
</Link>
|
||||
|
|
|
|||
Loading…
Reference in a new issue