food-market/src/food-market.api/Controllers/Common/UserPresetsController.cs
nns 6940aa40df
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
feat(s19): bulk-операции + presets + power-user UX (7 пунктов)
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>
2026-06-07 21:08:48 +05:00

121 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}