fix(ui): правки по ревью скриншотов — i18n, валюта, дубль групп, касса, фасовки
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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 Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

1. Английские строки в админке → русский: ru.json dashboard.title «Dashboard»→
   «Сводка», common.live/offline «live/offline»→«онлайн/офлайн»; дашборд-виджеты
   defaultValue «Low-stock alerts»→«Низкий остаток», «Live-обновление»→«Обновляется
   в реальном времени».
2. Каталог: системная группа «Все товары» больше не дублирует корень дерева
   (ProductGroupDto.IsSystem + ProductGroupTree прячет системные группы).
3. Валюта: розничная цена в каталоге теперь «₸», а не «KZT» (символ вместо кода).
4. Касса на сайте: вместо HTML-макета — НАСТОЯЩИЙ экран Avalonia-кассы
   (--screenshots) с витринными данными «Магазин у дома», кассир Виктория Ким, с
   логотипом; убран префикс «Магазин » в шапке (не было двойного «Магазин Магазин»).
   HTML-макет удалён из публичного пайплайна.
5. Витрина: фасовка/единица товара выводится из названия (кг→весовой, иначе
   штучный) — нет «Авокадо 1шт весовой» / «Бешбармак 1кг штучный».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 22:53:28 +05:00
parent bce417941c
commit d20cf15ca2
31 changed files with 53 additions and 258 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View file

@ -0,0 +1,20 @@
# Правки по ревью скриншотов сайта (Mac-Claude)
Замечания к новым скриншотам test.food-market.kz/screenshots/*.webp. Часть — ошибки
самой программы (не только скриншотов).
## План / статус
1. [ ] Английские строки в админке: «Dashboard», «Low-stock alerts», «live» и др. —
перевести; grep по web/src на видимые латинские строки; лингва-проверка в menu-walk.
2. [ ] Каталог: дубль «Все товары» (корень дерева + системная группа). Убрать дубль.
3. [ ] Валюта: везде «₸» и формат «1 300 ₸» (сейчас розничная — «510 KZT»).
4. [ ] Касса на сайте: заменить рендер HTML-макета на НАСТОЯЩИЙ экран Avalonia-кассы
(--screenshots, витринные данные «Магазин у дома», с логотипом). Макет не использовать.
5. [ ] Витрина: packaging по товарам (Авокадо 1шт — штучный, Бешбармак 1кг — весовой и т.п.).
6. [ ] Выложить сайт+стенд, переснять затронутые скриншоты, посмотреть каждый кадр.
## Что НЕ трогаем
Прод, shared-postgres, global.json, gateway nginx, sklad.
## Журнал
- 08.10 — старт: план, разбор замечаний.

View file

@ -45,7 +45,7 @@ public class ProductGroupsController : ControllerBase
}; };
var items = await q var items = await q
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId)) .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId, g.IsSystem))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -54,7 +54,7 @@ public class ProductGroupsController : ControllerBase
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
{ {
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId); return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId, g.IsSystem);
} }
[HttpPost, RequiresPermission("ProductGroupsManage")] [HttpPost, RequiresPermission("ProductGroupsManage")]

View file

@ -60,7 +60,6 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
var unitKg = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "166", ct); var unitKg = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "166", ct);
var unitSht = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "796", ct); var unitSht = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "796", ct);
var unitL = await _db.UnitsOfMeasure.IgnoreQueryFilters().FirstAsync(u => u.OrganizationId == null && u.Code == "112", ct);
var retailPrice = await _db.PriceTypes.IgnoreQueryFilters().FirstAsync(p => p.OrganizationId == orgId && p.IsRetail, ct); var retailPrice = await _db.PriceTypes.IgnoreQueryFilters().FirstAsync(p => p.OrganizationId == orgId && p.IsRetail, ct);
var currency = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct); var currency = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
@ -89,11 +88,17 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
long bcSeq = 487_600_000_001L; long bcSeq = 487_600_000_001L;
for (int g = 0; g < groups.Count; g++) for (int g = 0; g < groups.Count; g++)
{ {
var (priceMin, priceMax, unit, packaging) = YearDemoSeeder.GroupParams(g); // Цену берём из диапазона группы; фасовку/единицу — из НАЗВАНИЯ товара,
var grpUnit = unit switch { "kg" => unitKg, "l" => unitL, _ => unitSht }; // чтобы они всегда совпадали (нет «Авокадо 1шт — весовой» и «… 1кг — штучный»).
var (priceMin, priceMax, _, _) = YearDemoSeeder.GroupParams(g);
for (int i = 0; i < 25; i++) for (int i = 0; i < 25; i++)
{ {
var name = YearDemoSeeder.ProductNames[g][i]; var name = YearDemoSeeder.ProductNames[g][i];
// «кг» в названии → весовой товар (кг); всё остальное (штучные
// фасовки Nг/Nмл/Nл/Nшт, бутылки, пачки) → штучный.
var isWeight = name.Contains("кг");
var unitObj = isWeight ? unitKg : unitSht;
var packaging = isWeight ? Packaging.Weight : Packaging.Piece;
var raw = priceMin + (decimal)_rng.NextDouble() * (priceMax - priceMin); var raw = priceMin + (decimal)_rng.NextDouble() * (priceMax - priceMin);
var price = Math.Round(raw / 10m, 0) * 10m; // до 10 тенге var price = Math.Round(raw / 10m, 0) * 10m; // до 10 тенге
var p = new Product var p = new Product
@ -101,7 +106,7 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
OrganizationId = orgId, OrganizationId = orgId,
Name = name, Name = name,
Article = null, Article = null,
UnitOfMeasureId = grpUnit.Id, UnitOfMeasureId = unitObj.Id,
Vat = 12m, VatEnabled = true, Vat = 12m, VatEnabled = true,
ProductGroupId = groups[g].Id, ProductGroupId = groups[g].Id,
Packaging = packaging, Packaging = packaging,

View file

@ -29,7 +29,7 @@ public record RetailPointDto(
public record ProductGroupDto( public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
decimal? MarkupPercent, Guid? OrganizationId); decimal? MarkupPercent, Guid? OrganizationId, bool IsSystem = false);
public record CounterpartyDto( public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyType Type, Guid Id, string Name, string? LegalName, CounterpartyType Type,

View file

@ -47,7 +47,7 @@ public partial class MainWindowViewModel : ViewModelBase
partial void OnBadgesCompactChanged(bool value) { OnPropertyChanged(nameof(ShiftBadge)); OnPropertyChanged(nameof(QueueBadge)); } partial void OnBadgesCompactChanged(bool value) { OnPropertyChanged(nameof(ShiftBadge)); OnPropertyChanged(nameof(QueueBadge)); }
/// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary> /// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary>
public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000012 открыта с 09:00"; IsShiftOpen = true; StoreName = "Демо-магазин"; PointName = "Касса 1"; } public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Виктория Ким"; ShiftNumber = "С-2026-000012 открыта с 09:00"; IsShiftOpen = true; StoreName = "Магазин у дома"; PointName = "Касса 1"; }
public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); } public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); }

View file

@ -24,7 +24,7 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="24" VerticalAlignment="Center" Margin="24,0,0,0"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="24" VerticalAlignment="Center" Margin="24,0,0,0">
<StackPanel x:Name="StoreMeta" Orientation="Horizontal" Spacing="24" VerticalAlignment="Center"> <StackPanel x:Name="StoreMeta" Orientation="Horizontal" Spacing="24" VerticalAlignment="Center">
<TextBlock VerticalAlignment="Center" FontSize="15" Foreground="{DynamicResource PosTextMuted}"> <TextBlock VerticalAlignment="Center" FontSize="15" Foreground="{DynamicResource PosTextMuted}">
<Run Text="Магазин "/><Run Text="{Binding StoreName}" Foreground="{DynamicResource PosText}" FontWeight="SemiBold"/><Run Text=" · Точка "/><Run Text="{Binding PointName}" Foreground="{DynamicResource PosText}" FontWeight="SemiBold"/> <Run Text="{Binding StoreName}" Foreground="{DynamicResource PosText}" FontWeight="SemiBold"/><Run Text=" · Точка "/><Run Text="{Binding PointName}" Foreground="{DynamicResource PosText}" FontWeight="SemiBold"/>
</TextBlock> </TextBlock>
<Border Width="1" Height="28" Background="{DynamicResource PosBorder}"/> <Border Width="1" Height="28" Background="{DynamicResource PosBorder}"/>
</StackPanel> </StackPanel>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -1,219 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Касса — макет</title>
<meta name="viewport" content="width=1920">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--bg:#F4F6F5; --card:#FFFFFF; --line:#E3E8E6; --text:#0F1F17; --muted:#5F6F68;
--green:#16A34A; --green-d:#12863D; --green-soft:#E8F7EE; --red:#DC2626; --amber:#B45309; --amber-soft:#FEF3C7;
--r:14px; --gap:16px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif;font-size:16px;
width:1920px;height:1080px;overflow:hidden;font-variant-numeric:tabular-nums}
.app{display:grid;grid-template-rows:64px 1fr 40px;height:100%}
/* верхняя полоса */
.top{background:var(--card);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:24px;padding:0 24px}
.logo{font-weight:800;font-size:20px;color:var(--green);letter-spacing:.2px}
.logo span{color:var(--text);font-weight:600}
.sep{width:1px;height:28px;background:var(--line)}
.meta{color:var(--muted);font-size:15px}
.meta b{color:var(--text);font-weight:600}
.spacer{flex:1}
.chip{display:inline-flex;align-items:center;gap:8px;height:34px;padding:0 14px;border-radius:999px;font-size:14px;font-weight:600}
.chip i{width:8px;height:8px;border-radius:50%;display:block}
.chip.ok{background:var(--green-soft);color:var(--green-d)} .chip.ok i{background:var(--green)}
.chip.net{background:#EEF2F1;color:var(--muted)} .chip.net i{background:var(--green)}
/* основная область */
.main{display:grid;grid-template-columns:1fr 440px;gap:var(--gap);padding:var(--gap) 24px;min-height:0}
.left{display:flex;flex-direction:column;gap:var(--gap);min-height:0}
.search{height:64px;background:var(--card);border:2px solid var(--green);border-radius:var(--r);display:flex;align-items:center;gap:14px;padding:0 20px;
box-shadow:0 0 0 4px rgba(22,163,74,.12)}
.search svg{flex:none}
.search .ph{color:#8A9993;font-size:19px}
.search kbd{margin-left:auto}
.fav-head{display:flex;align-items:center;justify-content:space-between}
.h{font-size:15px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}
.link{font-size:14px;font-weight:600;color:var(--green-d)}
.fav{display:grid;grid-template-columns:repeat(6,1fr);gap:12px}
.tile{height:84px;background:var(--card);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:6px;text-align:center}
.tile .n{font-size:16px;font-weight:600}
.tile .p{font-size:15px;font-weight:700;color:var(--green-d)}
.cart{flex:1;background:var(--card);border:1px solid var(--line);border-radius:var(--r);display:flex;flex-direction:column;min-height:0}
.row{display:grid;grid-template-columns:1fr 200px 160px 56px;align-items:center;column-gap:16px;padding:0 20px}
.thead{height:48px;border-bottom:1px solid var(--line);color:var(--muted);font-size:14px;font-weight:600}
.thead .r{text-align:right}
.thead .c{text-align:center}
.item{height:76px;border-bottom:1px solid var(--line)}
.item.sel{background:#F7FBF8;box-shadow:inset 4px 0 0 var(--green)}
.name{font-size:18px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sub{font-size:14px;color:var(--muted);margin-top:4px}
.sub.disc{color:var(--green-d);font-weight:500}
.step{display:grid;grid-template-columns:44px 1fr 44px;align-items:center;height:44px;border:1px solid var(--line);border-radius:10px;overflow:hidden}
.step b{display:flex;align-items:center;justify-content:center;height:100%;font-size:22px;font-weight:500;color:var(--text);background:#F7F9F8}
.step span{text-align:center;font-size:17px;font-weight:600}
.sum{text-align:right;font-size:20px;font-weight:700}
.del{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#9AA7A1;justify-self:end}
/* правая панель */
.right{background:var(--card);border:1px solid var(--line);border-radius:var(--r);display:flex;flex-direction:column;padding:24px;gap:16px}
.kv{display:flex;justify-content:space-between;font-size:17px;color:var(--muted)}
.kv b{color:var(--text);font-weight:600}
.kv.disc b{color:var(--green-d)}
.total{background:var(--green-soft);border-radius:12px;padding:20px 22px;display:flex;justify-content:space-between;align-items:baseline}
.total .l{font-size:18px;font-weight:700;color:var(--green-d);text-transform:uppercase;letter-spacing:.5px}
.total .v{font-size:56px;font-weight:800;color:var(--green-d);line-height:1}
.last{margin-top:4px;border-top:1px solid var(--line);padding-top:16px;color:var(--muted);font-size:14px;display:flex;flex-direction:column;gap:10px}
.last div{display:flex;justify-content:space-between}
.last b{color:var(--text);font-weight:600}
.grow{flex:1}
.pay{height:76px;border-radius:12px;background:var(--green);color:#fff;font-size:24px;font-weight:700;display:flex;align-items:center;justify-content:center;gap:12px;
box-shadow:0 6px 16px rgba(22,163,74,.28)}
.pay kbd{background:rgba(255,255,255,.2);border-color:transparent;color:#fff}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.btn{height:52px;border:1px solid var(--line);border-radius:10px;background:var(--card);display:flex;align-items:center;justify-content:center;gap:10px;
font-size:16px;font-weight:600;color:var(--text)}
.btn svg{color:var(--muted)}
.btn kbd{font-size:12px}
.btn.danger{grid-column:1/3;border-color:#F3C4C4;color:var(--red)}
.btn.danger svg{color:var(--red)}
.badge{min-width:22px;height:22px;border-radius:11px;background:var(--amber-soft);color:var(--amber);font-size:13px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;padding:0 6px}
kbd{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:24px;padding:0 7px;border:1px solid var(--line);border-bottom-width:2px;
border-radius:6px;background:#fff;font:600 12px Inter,sans-serif;color:var(--muted)}
/* полоса клавиш */
.keys{display:flex;align-items:center;gap:28px;padding:0 24px;color:var(--muted);font-size:14px;border-top:1px solid var(--line);background:var(--card)}
.keys span{display:inline-flex;align-items:center;gap:8px}
/* модальное окно оплаты */
.shade{position:fixed;inset:0;background:rgba(15,31,23,.42);display:flex;align-items:center;justify-content:center}
.modal{width:880px;background:var(--card);border-radius:20px;box-shadow:0 24px 64px rgba(0,0,0,.25);padding:32px;display:flex;flex-direction:column;gap:24px}
.mh{display:flex;justify-content:space-between;align-items:baseline}
.mh .t{font-size:24px;font-weight:700}
.mh .a{font-size:16px;color:var(--muted)} .mh .a b{font-size:40px;color:var(--text);font-weight:800;margin-left:10px}
.seg{display:grid;grid-template-columns:repeat(3,1fr);background:#EEF2F1;border-radius:12px;padding:4px;gap:4px}
.seg div{height:52px;border-radius:9px;display:flex;align-items:center;justify-content:center;gap:10px;font-size:17px;font-weight:600;color:var(--muted)}
.seg .on{background:#fff;color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.12)}
.pbody{display:grid;grid-template-columns:1fr 300px;gap:24px}
.lbl{font-size:14px;font-weight:600;color:var(--muted);margin-bottom:8px}
.inp{height:64px;border:2px solid var(--green);border-radius:12px;display:flex;align-items:center;justify-content:flex-end;padding:0 20px;font-size:30px;font-weight:700}
.quick{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}
.quick div{height:52px;border:1px solid var(--line);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:600}
.quick .ex{color:var(--green-d);border-color:#BFE6CD;background:var(--green-soft)}
.change{margin-top:14px;background:var(--green-soft);border-radius:12px;height:84px;display:flex;align-items:center;justify-content:space-between;padding:0 22px}
.change .l{font-size:16px;font-weight:700;color:var(--green-d);text-transform:uppercase;letter-spacing:.5px}
.change .v{font-size:44px;font-weight:800;color:var(--green-d)}
.pad{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.pad div{height:72px;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:600;background:#FAFBFA}
.pad .s{font-size:16px;color:var(--muted)}
.actions{display:grid;grid-template-columns:200px 1fr;gap:12px}
.back{height:68px;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;justify-content:center;gap:10px;font-size:17px;font-weight:600}
.go{height:68px;border-radius:12px;background:var(--green);color:#fff;display:flex;align-items:center;justify-content:center;gap:12px;font-size:21px;font-weight:700}
.go kbd{background:rgba(255,255,255,.2);border-color:transparent;color:#fff}
body.nomodal .shade{display:none}
</style>
</head>
<body>
<div class="app">
<header class="top">
<div class="logo">ALL MARKET <span>· Касса</span></div>
<div class="sep"></div>
<div class="meta"><b>Магазин у дома</b> · Точка <b>Касса 1</b></div>
<div class="sep"></div>
<div class="meta">Кассир <b>Виктория Ким</b></div>
<div class="spacer"></div>
<div class="chip ok"><i></i>Смена С-2026-000012 открыта с 09:00</div>
<div class="chip net"><i></i>На связи · очередь пуста</div>
</header>
<section class="main">
<div class="left">
<div class="search">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#16A34A" stroke-width="2"><path d="M3 5v14M7 5v14M11 5v14M15 5v14M19 5v14" stroke-linecap="round"/></svg>
<div class="ph">Отсканируйте товар или начните вводить название</div>
<kbd>F2</kbd>
</div>
<div class="fav-head"><div class="h">Избранное</div><div class="link">Скрыть ▴</div></div>
<div class="fav">
<div class="tile"><div class="n">Хлеб «Семейный»</div><div class="p">280 ₸</div></div>
<div class="tile"><div class="n">Молоко 2,5%</div><div class="p">450 ₸</div></div>
<div class="tile"><div class="n">Яблоки (вес)</div><div class="p">690 ₸/кг</div></div>
<div class="tile"><div class="n">Картофель (вес)</div><div class="p">280 ₸/кг</div></div>
<div class="tile"><div class="n">Пакет-майка</div><div class="p">20 ₸</div></div>
<div class="tile"><div class="n">Вода 0,5 л</div><div class="p">150 ₸</div></div>
</div>
<div class="cart">
<div class="row thead"><div>Товар</div><div class="c">Количество</div><div class="r">Сумма</div><div></div></div>
<div class="row item"><div><div class="name">Хлеб «Семейный» нарезной</div><div class="sub">280 ₸ за шт</div></div>
<div class="step"><b>−</b><span>2 шт</span><b>+</b></div><div class="sum">560 ₸</div>
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
<div class="row item"><div><div class="name">Молоко 2,5% 1 л</div><div class="sub">450 ₸ за шт</div></div>
<div class="step"><b>−</b><span>1 шт</span><b>+</b></div><div class="sum">450 ₸</div>
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
<div class="row item"><div><div class="name">Яблоки «Апорт»</div><div class="sub">690 ₸ за кг</div></div>
<div class="step"><b>−</b><span>1,235 кг</span><b>+</b></div><div class="sum">852 ₸</div>
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
<div class="row item sel"><div><div class="name">Сахар-песок 1 кг</div><div class="sub disc">520 ₸ за шт · скидка 60 ₸ (1 560 → 1 500 ₸)</div></div>
<div class="step"><b>−</b><span>3 шт</span><b>+</b></div><div class="sum">1 500 ₸</div>
<div class="del"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg></div></div>
</div>
</div>
<aside class="right">
<div class="kv"><span>Позиций</span><b>4</b></div>
<div class="kv"><span>Подытог</span><b>3 422 ₸</b></div>
<div class="kv disc"><span>Скидка</span><b>−60 ₸</b></div>
<div class="total"><div class="l">Итого</div><div class="v">3 362 ₸</div></div>
<div class="last">
<div><span>Последний чек № 000148</span><b>1 250 ₸ · нал</b></div>
<div><span>Чеков за смену</span><b>47</b></div>
</div>
<div class="grow"></div>
<div class="pay">Оплатить <kbd>F12</kbd></div>
<div class="grid2">
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 5L5 19M7 7h.01M17 17h.01"/></svg>Скидка <kbd>F4</kbd></div>
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4h12v16l-6-4-6 4z"/></svg>Отложить <kbd>F7</kbd></div>
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Отложенные <span class="badge">2</span></div>
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3"/></svg>Возврат <kbd>F9</kbd></div>
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>Внесение</div>
<div class="btn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/></svg>Выплата</div>
<div class="btn danger"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>Очистить чек <kbd>Esc</kbd></div>
</div>
</aside>
</section>
<footer class="keys">
<span><kbd>F1</kbd>Помощь</span><span><kbd>F2</kbd>Поиск</span><span><kbd>F4</kbd>Скидка</span><span><kbd>F7</kbd>Отложить</span>
<span><kbd>F9</kbd>Возврат</span><span><kbd>F10</kbd>Смена</span><span><kbd>F12</kbd>Оплата</span><span><kbd>Del</kbd>Удалить строку</span><span><kbd>Esc</kbd>Отмена</span>
</footer>
</div>
<div class="shade">
<div class="modal">
<div class="mh"><div class="t">Оплата</div><div class="a">К оплате<b>3 362 ₸</b></div></div>
<div class="seg"><div class="on">💵 Наличные</div><div>💳 Карта</div><div>⇄ Смешанная</div></div>
<div class="pbody">
<div>
<div class="lbl">Получено наличными</div>
<div class="inp">5 000 ₸</div>
<div class="quick"><div class="ex">Без сдачи</div><div>4 000</div><div>5 000</div><div>10 000</div><div>20 000</div><div>Своя сумма</div></div>
<div class="change"><div class="l">Сдача</div><div class="v">1 638 ₸</div></div>
</div>
<div class="pad"><div>1</div><div>2</div><div>3</div><div>4</div><div>5</div><div>6</div><div>7</div><div>8</div><div>9</div><div class="s">Сброс</div><div>0</div><div class="s">⌫</div></div>
</div>
<div class="actions"><div class="back"><kbd>Esc</kbd>Назад</div><div class="go">Провести и печатать <kbd>Enter</kbd></div></div>
</div>
</div>
</body>
</html>

View file

@ -3,7 +3,7 @@
// Снимает СТЕНД (test.admin.food-market.kz) под витринной организацией // Снимает СТЕНД (test.admin.food-market.kz) под витринной организацией
// «Магазин у дома» (создаётся deploy/showcase-seed.sh — чистые данные, без // «Магазин у дома» (создаётся deploy/showcase-seed.sh — чистые данные, без
// e2e/test-мусора). Светлая тема, 1440×900 @2x. Плюс рендерит макет новой // e2e/test-мусора). Светлая тема, 1440×900 @2x. Плюс рендерит макет новой
// светлой кассы (scripts/pos-mockup/pos-mockup.html) → продажа + оплата. // Касса (продажа/оплата) снимается отдельно — настоящий Avalonia-экран.
// //
// Предусловие: на стенде поднят API с ShowcaseSeeder и выполнен showcase-seed. // Предусловие: на стенде поднят API с ShowcaseSeeder и выполнен showcase-seed.
// bash deploy/showcase-seed.sh // bash deploy/showcase-seed.sh
@ -18,7 +18,7 @@
// CHROME — путь к chrome (по умолч. /usr/bin/google-chrome) // CHROME — путь к chrome (по умолч. /usr/bin/google-chrome)
import { chromium } from 'playwright' import { chromium } from 'playwright'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url' import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OUT = path.resolve(__dirname, '..', 'public', 'screenshots') const OUT = path.resolve(__dirname, '..', 'public', 'screenshots')
@ -166,26 +166,12 @@ for (const s of shots) {
} }
} }
// ── Макет новой светлой кассы (локальный HTML) ────────────────────────────── // POS (касса): скриншоты НЕ здесь. Это настоящий экран Avalonia-кассы, он
const posHtml = pathToFileURL(path.resolve(__dirname, 'pos-mockup', 'pos-mockup.html')).href // рендерится отдельно (headless Skia) и кладётся в docs/pos-screenshots/:
const posCtx = await browser.newContext({ // dotnet run --project src/food-market.pos -c Release -- --screenshots
viewport: { width: 1920, height: 1080 }, // На сайт берём 04-sale-items (продажа) и 05-payment-cash (оплата) →
deviceScaleFactor: 1, // public/screenshots/pos-sale.png / pos-pay.png → optimize-screenshots.mjs.
colorScheme: 'light', // HTML-макет кассы на сайте НЕ используется.
})
const posPage = await posCtx.newPage()
console.log('pos mockup →', posHtml)
// Экран оплаты (модалка видна).
await posPage.goto(posHtml, { waitUntil: 'networkidle' })
await posPage.waitForTimeout(400)
await posPage.screenshot({ path: path.join(OUT, 'pos-pay.png') })
console.log(' ok → pos-pay.png')
// Экран продажи (прячем модалку через body.nomodal).
await posPage.evaluate(() => document.body.classList.add('nomodal'))
await posPage.waitForTimeout(200)
await posPage.screenshot({ path: path.join(OUT, 'pos-sale.png') })
console.log(' ok → pos-sale.png')
await posCtx.close()
await browser.close() await browser.close()
console.log('done') console.log('done')

View file

@ -109,7 +109,7 @@ export function LowStockWidget({ limit = 10 }: { limit?: number }) {
return ( return (
<WidgetCard <WidgetCard
title={t('dashboard.lowStock.title', { defaultValue: 'Low-stock alerts' })} title={t('dashboard.lowStock.title', { defaultValue: 'Низкий остаток' })}
hint={t('dashboard.lowStock.hint', { defaultValue: 'Остаток ≤ минимума' })} hint={t('dashboard.lowStock.hint', { defaultValue: 'Остаток ≤ минимума' })}
icon={AlertTriangle} icon={AlertTriangle}
footer={( footer={(
@ -169,7 +169,7 @@ export function RecentSalesWidget({ limit = 10 }: { limit?: number }) {
return ( return (
<WidgetCard <WidgetCard
title={t('dashboard.recentSales.title', { defaultValue: 'Последние продажи' })} title={t('dashboard.recentSales.title', { defaultValue: 'Последние продажи' })}
hint={t('dashboard.recentSales.hint', { defaultValue: 'Live-обновление' })} hint={t('dashboard.recentSales.hint', { defaultValue: 'Обновляется в реальном времени' })}
icon={ShoppingCart} icon={ShoppingCart}
footer={( footer={(
<Link to="/sales/retail" className="text-xs text-emerald-700 dark:text-emerald-400 hover:underline"> <Link to="/sales/retail" className="text-xs text-emerald-700 dark:text-emerald-400 hover:underline">

View file

@ -35,7 +35,10 @@ interface Props {
export function ProductGroupTree({ selectedId, onSelect }: Props) { export function ProductGroupTree({ selectedId, onSelect }: Props) {
const { data: groups, isLoading } = useProductGroups() const { data: groups, isLoading } = useProductGroups()
const tree = useMemo(() => buildTree(groups ?? []), [groups]) // Системную группу «Все товары» (bootstrap-дефолт для товаров без группы) в
// дереве не показываем — её роль уже играет корневой пункт «Все товары»
// (по модели МойСклада корень = все товары, без отдельной одноимённой группы).
const tree = useMemo(() => buildTree((groups ?? []).filter((g) => !g.isSystem)), [groups])
const [expanded, setExpanded] = useState<Set<string>>(new Set()) const [expanded, setExpanded] = useState<Set<string>>(new Set())
const toggle = (id: string) => const toggle = (id: string) =>

View file

@ -41,7 +41,7 @@ export interface RetailPoint {
id: string; name: string; code: string | null; storeId: string; storeName: string | null; id: string; name: string; code: string | null; storeId: string; storeName: string | null;
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
} }
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null } export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null; isSystem?: boolean }
export interface Counterparty { export interface Counterparty {
id: string; name: string; legalName: string | null; type: CounterpartyType; id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null; bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;

View file

@ -38,8 +38,8 @@
"yesterday": "Вчера", "yesterday": "Вчера",
"today": "Сегодня", "today": "Сегодня",
"month": "Месяц", "month": "Месяц",
"live": "live", "live": "онлайн",
"offline": "offline" "offline": "офлайн"
}, },
"nav": { "nav": {
"section_main": "Главное", "section_main": "Главное",
@ -98,7 +98,7 @@
"supplierDebts": "Долги поставщикам" "supplierDebts": "Долги поставщикам"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Сводка",
"welcome": "Добро пожаловать, {{name}}", "welcome": "Добро пожаловать, {{name}}",
"fallbackDescription": "Сводка по продажам и каталогу", "fallbackDescription": "Сводка по продажам и каталогу",
"revenueToday": "Выручка сегодня", "revenueToday": "Выручка сегодня",

View file

@ -231,7 +231,7 @@ export function ProductsPage() {
fractional fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 } ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }) : { maximumFractionDigits: 0 })
return `${num} ${pr?.currencyCode ?? org.data?.defaultCurrencySymbol ?? ''}`.trim() return `${num} ${org.data?.defaultCurrencySymbol ?? pr?.currencyCode ?? ''}`.trim()
} }
// Sprint 19: dblclick → inline-edit. priceTypeId=systemPriceType.id; // Sprint 19: dblclick → inline-edit. priceTypeId=systemPriceType.id;
// если PriceType ещё нет в БД (priceTypes только подгружаются) // если PriceType ещё нет в БД (priceTypes только подгружаются)