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>
121 lines
5.2 KiB
C#
121 lines
5.2 KiB
C#
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();
|
||
}
|
||
}
|