feat(pos): избранные товары (плитки) + переключатель тёмной темы

- Избранное: Product.IsFavorite/FavoriteOrder (миграция Phase37), в POS-синке
  (ProductSyncDto) и в карточке товара (ProductInput). Касса: PosStore хранит
  флаг (+ALTER для старых БД) и GetFavorites(); на экране продажи — сворачиваемая
  сетка плиток «быстрых товаров» (товары без штрихкода: хлеб, овощи на вес),
  тап добавляет в чек. Unit-тест GetFavorites (порядок/фильтр). 44 POS unit зелёные.
- Тёмная тема — опция: кнопка-переключатель в шапке (ToggleTheme), выбор
  сохраняется на устройстве (kv device.theme), применяется при старте. На тех же
  токенах (ThemeDictionaries Light/Dark). По умолчанию — светлая. Кадр «тёмная тема» в галерее.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 13:35:56 +05:00
parent 7a93c1941d
commit ac55da8d06
23 changed files with 183 additions and 12 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

File diff suppressed because one or more lines are too long

View file

@ -1300,5 +1300,7 @@ private static void Apply(Product e, ProductInput i)
} }
e.PurchaseCurrencyId = i.PurchaseCurrencyId; e.PurchaseCurrencyId = i.PurchaseCurrencyId;
e.ImageUrl = i.ImageUrl; e.ImageUrl = i.ImageUrl;
e.IsFavorite = i.IsFavorite;
e.FavoriteOrder = i.FavoriteOrder;
} }
} }

View file

@ -145,6 +145,8 @@ orderby l.SortOrder
// Product не имеет IsArchived в текущей доменной модели; всегда false. // Product не имеет IsArchived в текущей доменной модели; всегда false.
// Когда добавим архив — заполняется здесь, без правки контракта. // Когда добавим архив — заполняется здесь, без правки контракта.
IsArchived = false, IsArchived = false,
IsFavorite = x.p.IsFavorite,
FavoriteOrder = x.p.FavoriteOrder,
UpdatedAt = x.stamp, UpdatedAt = x.stamp,
}).ToList(); }).ToList();

View file

@ -100,4 +100,5 @@ public record ProductInput(
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null, [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
[StringLength(1000)] string? ImageUrl = null, [StringLength(1000)] string? ImageUrl = null,
IReadOnlyList<ProductPriceInput>? Prices = null, IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null); IReadOnlyList<ProductBarcodeInput>? Barcodes = null,
bool IsFavorite = false, int FavoriteOrder = 0);

View file

@ -73,6 +73,12 @@ public class Product : TenantEntity
/// вне сезона), архивный — навсегда (списан).</summary> /// вне сезона), архивный — навсегда (списан).</summary>
public bool IsAvailableForSale { get; set; } = true; public bool IsAvailableForSale { get; set; } = true;
/// <summary>Товар в «Избранном» кассы — показывается плиткой на экране продажи
/// (обычно товары без штрихкода: хлеб, овощи на вес). Настраивает админ.</summary>
public bool IsFavorite { get; set; }
/// <summary>Порядок плитки в «Избранном» (меньше — раньше).</summary>
public int FavoriteOrder { get; set; }
public ICollection<ProductPrice> Prices { get; set; } = []; public ICollection<ProductPrice> Prices { get; set; } = [];
public ICollection<ProductBarcode> Barcodes { get; set; } = []; public ICollection<ProductBarcode> Barcodes { get; set; } = [];
public ICollection<ProductImage> Images { get; set; } = []; public ICollection<ProductImage> Images { get; set; } = [];

View file

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase37 — «Избранное» кассы: товары-плитки на экране продажи
/// (обычно без штрихкода — хлеб, овощи на вес). Добавляет в <c>products</c>:
/// <list type="bullet">
/// <item><c>IsFavorite</c> boolean DEFAULT false.</item>
/// <item><c>FavoriteOrder</c> integer DEFAULT 0.</item>
/// </list>
/// Идемпотентно. Отдаётся в POS-синке (ProductSyncDto).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261008120000_Phase37_ProductFavorite")]
public partial class Phase37_ProductFavorite : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS ""IsFavorite"" boolean NOT NULL DEFAULT false;
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS ""FavoriteOrder"" integer NOT NULL DEFAULT 0;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.products DROP COLUMN IF EXISTS ""IsFavorite"";
ALTER TABLE public.products DROP COLUMN IF EXISTS ""FavoriteOrder"";
");
}
}
}

View file

@ -34,7 +34,8 @@ private void InitSchema()
CREATE TABLE IF NOT EXISTS products( CREATE TABLE IF NOT EXISTS products(
Id TEXT PRIMARY KEY, Name TEXT NOT NULL, NameLower TEXT NOT NULL DEFAULT '', Article TEXT, UnitCode TEXT NOT NULL, Id TEXT PRIMARY KEY, Name TEXT NOT NULL, NameLower TEXT NOT NULL DEFAULT '', Article TEXT, UnitCode TEXT NOT NULL,
Packaging INTEGER NOT NULL, VatPercent TEXT NOT NULL, VatEnabled INTEGER NOT NULL, Packaging INTEGER NOT NULL, VatPercent TEXT NOT NULL, VatEnabled INTEGER NOT NULL,
Price TEXT NOT NULL DEFAULT '0', IsArchived INTEGER NOT NULL DEFAULT 0, UpdatedAt TEXT); Price TEXT NOT NULL DEFAULT '0', IsArchived INTEGER NOT NULL DEFAULT 0, UpdatedAt TEXT,
IsFavorite INTEGER NOT NULL DEFAULT 0, FavoriteOrder INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS barcodes(Code TEXT NOT NULL, ProductId TEXT NOT NULL); CREATE TABLE IF NOT EXISTS barcodes(Code TEXT NOT NULL, ProductId TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_barcodes_code ON barcodes(Code); CREATE INDEX IF NOT EXISTS ix_barcodes_code ON barcodes(Code);
CREATE INDEX IF NOT EXISTS ix_barcodes_productid ON barcodes(ProductId); CREATE INDEX IF NOT EXISTS ix_barcodes_productid ON barcodes(ProductId);
@ -43,6 +44,15 @@ private void InitSchema()
ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0, ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT NOT NULL, Error TEXT); CreatedAt TEXT NOT NULL, Error TEXT);
CREATE TABLE IF NOT EXISTS kv(Key TEXT PRIMARY KEY, Value TEXT);"); CREATE TABLE IF NOT EXISTS kv(Key TEXT PRIMARY KEY, Value TEXT);");
// Для БД, созданных до «Избранного» — добавляем колонки (SQLite не умеет
// IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»).
TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0");
TryExec("ALTER TABLE products ADD COLUMN FavoriteOrder INTEGER NOT NULL DEFAULT 0");
}
private void TryExec(string sql)
{
try { Exec(sql); } catch { /* колонка уже есть */ }
} }
// ── каталог ────────────────────────────────────────────────────────────── // ── каталог ──────────────────────────────────────────────────────────────
@ -57,11 +67,11 @@ public void ApplySync(PosSyncResponse sync)
// Подготовленные команды с переиспользуемыми параметрами — массовая // Подготовленные команды с переиспользуемыми параметрами — массовая
// вставка 30k товаров за ~1с вместо десятков секунд (команда на строку). // вставка 30k товаров за ~1с вместо десятков секунд (команда на строку).
var up = _cn.CreateCommand(); up.Transaction = tx; var up = _cn.CreateCommand(); up.Transaction = tx;
up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt) up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt,IsFavorite,FavoriteOrder)
VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up) VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up,$fav,$favo)
ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve, ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve,
Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up;"; Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up, IsFavorite=$fav, FavoriteOrder=$favo;";
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up"); var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up", "$fav", "$favo");
up.Prepare(); up.Prepare();
var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id"; var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id";
var delP = AddParams(delBc, "$id"); delBc.Prepare(); var delP = AddParams(delBc, "$id"); delBc.Prepare();
@ -79,6 +89,7 @@ public void ApplySync(PosSyncResponse sync)
upP["$ve"].Value = p.VatEnabled ? 1 : 0; upP["$ve"].Value = p.VatEnabled ? 1 : 0;
upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture); upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture);
upP["$hasPrice"].Value = hasPrice ? 1 : 0; upP["$ar"].Value = p.IsArchived ? 1 : 0; upP["$up"].Value = p.UpdatedAt.ToString("O"); upP["$hasPrice"].Value = hasPrice ? 1 : 0; upP["$ar"].Value = p.IsArchived ? 1 : 0; upP["$up"].Value = p.UpdatedAt.ToString("O");
upP["$fav"].Value = p.IsFavorite ? 1 : 0; upP["$favo"].Value = p.FavoriteOrder;
up.ExecuteNonQuery(); up.ExecuteNonQuery();
delP["$id"].Value = idStr; delBc.ExecuteNonQuery(); delP["$id"].Value = idStr; delBc.ExecuteNonQuery();
foreach (var bc in p.Barcodes) foreach (var bc in p.Barcodes)
@ -123,6 +134,18 @@ public int ProductCount()
return r.Read() ? Map(r) : null; return r.Read() ? Map(r) : null;
} }
/// <summary>Избранные товары для плиток «быстрых товаров» на экране продажи.</summary>
public IReadOnlyList<PosProduct> GetFavorites(int limit = 12)
{
using var cmd = _cn.CreateCommand();
cmd.CommandText = "SELECT * FROM products WHERE IsFavorite=1 AND IsArchived=0 ORDER BY FavoriteOrder, Name LIMIT $l";
Bind(cmd, "$l", limit.ToString());
var list = new List<PosProduct>();
using var r = cmd.ExecuteReader();
while (r.Read()) list.Add(Map(r));
return list;
}
public IReadOnlyList<PosProduct> SearchByName(string term, int limit = 50) public IReadOnlyList<PosProduct> SearchByName(string term, int limit = 50)
{ {
term = (term ?? string.Empty).Trim(); term = (term ?? string.Empty).Trim();

View file

@ -18,6 +18,7 @@ public sealed class PosProduct
public required bool VatEnabled { get; init; } public required bool VatEnabled { get; init; }
public required decimal Price { get; init; } public required decimal Price { get; init; }
public bool IsArchived { get; init; } public bool IsArchived { get; init; }
public bool IsFavorite { get; init; }
public bool IsWeighed => Packaging == PosPackaging.Weight; public bool IsWeighed => Packaging == PosPackaging.Weight;
} }

View file

@ -32,6 +32,8 @@ public override void OnFrameworkInitializationCompleted()
else else
{ {
var services = new AppServices(); var services = new AppServices();
// Тема устройства (по умолчанию светлая; тёмная — опция в настройках).
try { if (services.Store.GetKv("device.theme") == "Dark") RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark; } catch { }
desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(services) }; desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(services) };
} }
} }

View file

@ -41,6 +41,7 @@ public static int Run(string[] args)
new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }), new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }),
new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()), new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()),
new("10-shell-online", "Статус-бар: на связи, очередь пуста", () => ShellOnline()), new("10-shell-online", "Статус-бар: на связи, очередь пуста", () => ShellOnline()),
new("11-shell-dark", "Тёмная тема (опция в настройках, не по умолчанию)", () => ShellDark()),
}; };
var sizes = new (string suffix, int w, int h)[] { ("1920x1080", 1920, 1080), ("1366x768", 1366, 768) }; var sizes = new (string suffix, int w, int h)[] { ("1920x1080", 1920, 1080), ("1366x768", 1366, 768) };
@ -80,6 +81,13 @@ private static Control ShellOnline()
var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" }; var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
return new MainWindow { DataContext = vm }; return new MainWindow { DataContext = vm };
} }
private static Control ShellDark()
{
// Переключаем тему на тёмную для этого кадра (последний — не влияет на прочие).
if (Avalonia.Application.Current is { } app) app.RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark;
var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
return new MainWindow { DataContext = vm };
}
private static void WriteGallery(string dir, List<(string file, string caption)> shots) private static void WriteGallery(string dir, List<(string file, string caption)> shots)
{ {

View file

@ -94,6 +94,18 @@ private static string LocalVersion()
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } }; [RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } };
[RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc); [RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc);
/// <summary>Переключить светлую/тёмную тему (тёмная — опция, по умолчанию светлая).
/// Выбор сохраняется на устройстве.</summary>
[RelayCommand]
private void ToggleTheme()
{
var app = Avalonia.Application.Current;
if (app is null) return;
var dark = app.ActualThemeVariant != Avalonia.Styling.ThemeVariant.Dark;
app.RequestedThemeVariant = dark ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light;
_svc?.Store.SetKv("device.theme", dark ? "Dark" : "Light");
}
private void OpenPayment(PaymentViewModel pvm) private void OpenPayment(PaymentViewModel pvm)
{ {
pvm.OnCancel = ClosePayment; pvm.OnCancel = ClosePayment;
@ -365,6 +377,9 @@ public partial class SaleViewModel : ViewModelBase
private readonly Cart _cart = new(); private readonly Cart _cart = new();
private readonly Stack<CartSnapshot> _parked = new(); private readonly Stack<CartSnapshot> _parked = new();
public ObservableCollection<SaleLineVm> Lines { get; } = new(); public ObservableCollection<SaleLineVm> Lines { get; } = new();
public ObservableCollection<PosProduct> Favorites { get; } = new();
[ObservableProperty] private bool _favoritesOpen = true;
public bool HasFavorites => Favorites.Count > 0;
[ObservableProperty] private string _scanInput = ""; [ObservableProperty] private string _scanInput = "";
[ObservableProperty] private string _shiftNumber = "С-2026-000001"; [ObservableProperty] private string _shiftNumber = "С-2026-000001";
@ -385,7 +400,17 @@ public partial class SaleViewModel : ViewModelBase
public Action<CheckoutService.CommitResult>? SaleCommitted; public Action<CheckoutService.CommitResult>? SaleCommitted;
public SaleViewModel() { } public SaleViewModel() { }
public SaleViewModel(AppServices svc) { _svc = svc; } public SaleViewModel(AppServices svc) { _svc = svc; LoadFavorites(); }
private void LoadFavorites()
{
Favorites.Clear();
if (_svc is not null) foreach (var p in _svc.Store.GetFavorites()) Favorites.Add(p);
OnPropertyChanged(nameof(HasFavorites));
}
[RelayCommand] private void AddFavorite(PosProduct p) => AddProduct(p, 1m);
[RelayCommand] private void ToggleFavorites() => FavoritesOpen = !FavoritesOpen;
private void Recalc() private void Recalc()
{ {
@ -531,6 +556,13 @@ public static SaleViewModel Sample()
vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2); vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2);
vm.ItemCount = vm.Lines.Count; vm.ItemCount = vm.Lines.Count;
vm.Message = "Чек готов к оплате — F12 Оплата"; vm.Message = "Чек готов к оплате — F12 Оплата";
PosProduct Fav(string n, string unit, decimal price, bool weighed) => new()
{ Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty<string>(), UnitCode = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true };
foreach (var f in new[] {
Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2.5%", "шт", 450m, false),
Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true),
Fav("Пакет-майка", "шт", 20m, false), Fav("Вода 0,5л", "шт", 150m, false) })
vm.Favorites.Add(f);
return vm; return vm;
} }
} }

View file

@ -32,6 +32,10 @@
<TextBlock Text="{Binding QueueBadge}" IsVisible="{Binding QueueBadge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> <TextBlock Text="{Binding QueueBadge}" IsVisible="{Binding QueueBadge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
</Border> </Border>
<Button Classes="pos ghost" Width="42" Height="38" Padding="0" Command="{Binding ToggleThemeCommand}" ToolTip.Tip="Тёмная / светлая тема">
<PathIcon Width="18" Height="18" Foreground="{DynamicResource PosTextMuted}"
Data="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/>
</Button>
<Border Width="1" Height="26" Background="{DynamicResource PosBorder}" Margin="4,0"/> <Border Width="1" Height="26" Background="{DynamicResource PosBorder}" Margin="4,0"/>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Right"> <StackPanel VerticalAlignment="Center" HorizontalAlignment="Right">
<TextBlock Text="{Binding CashierName}" Foreground="{DynamicResource PosText}" FontSize="14" FontWeight="SemiBold" HorizontalAlignment="Right"/> <TextBlock Text="{Binding CashierName}" Foreground="{DynamicResource PosText}" FontSize="14" FontWeight="SemiBold" HorizontalAlignment="Right"/>

View file

@ -5,8 +5,8 @@
x:DataType="vm:SaleViewModel"> x:DataType="vm:SaleViewModel">
<Grid ColumnDefinitions="*,400" Background="{DynamicResource PosBg}"> <Grid ColumnDefinitions="*,400" Background="{DynamicResource PosBg}">
<!-- ЛЕВО: поиск + корзина --> <!-- ЛЕВО: поиск + избранное + корзина -->
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto" Margin="20,18,10,18"> <Grid Grid.Column="0" RowDefinitions="Auto,Auto,*,Auto" Margin="20,18,10,18">
<!-- Поиск / скан --> <!-- Поиск / скан -->
<Border Grid.Row="0" Classes="card" Padding="0" Margin="0,0,0,14"> <Border Grid.Row="0" Classes="card" Padding="0" Margin="0,0,0,14">
@ -20,8 +20,30 @@
</Grid> </Grid>
</Border> </Border>
<!-- Избранное (быстрые товары) -->
<StackPanel Grid.Row="1" Spacing="8" Margin="0,0,0,14" IsVisible="{Binding HasFavorites}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Избранное" Foreground="{DynamicResource PosTextMuted}" FontSize="13.5" FontWeight="Medium" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="pos ghost" Height="30" Padding="10,0" FontSize="12.5" Content="Свернуть/развернуть" Command="{Binding ToggleFavoritesCommand}"/>
</Grid>
<ItemsControl ItemsSource="{Binding Favorites}" IsVisible="{Binding FavoritesOpen}">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Classes="pos ghost" Height="72" Margin="4" Padding="6,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:SaleViewModel)DataContext).AddFavoriteCommand}" CommandParameter="{Binding}">
<StackPanel Spacing="2" HorizontalAlignment="Stretch">
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold" TextAlignment="Center" TextWrapping="Wrap" HorizontalAlignment="Stretch" Foreground="{DynamicResource PosText}"/>
<TextBlock Text="{Binding Price, StringFormat='{}{0:N0} ₸'}" FontSize="13" TextAlignment="Center" HorizontalAlignment="Stretch" Foreground="{DynamicResource PosBrand}"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Корзина --> <!-- Корзина -->
<Border Grid.Row="1" Classes="card" Padding="0"> <Border Grid.Row="2" Classes="card" Padding="0">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,170,120,44" Height="40" Margin="18,6,10,0"> <Grid Grid.Row="0" ColumnDefinitions="*,170,120,44" Height="40" Margin="18,6,10,0">
<TextBlock Text="Товар" Foreground="{DynamicResource PosTextMuted}" FontSize="13" VerticalAlignment="Center"/> <TextBlock Text="Товар" Foreground="{DynamicResource PosTextMuted}" FontSize="13" VerticalAlignment="Center"/>
@ -74,7 +96,7 @@
</Grid> </Grid>
</Border> </Border>
<TextBlock Grid.Row="2" Text="{Binding Message}" Foreground="{DynamicResource PosTextMuted}" Margin="4,10,0,0" FontSize="13.5"/> <TextBlock Grid.Row="3" Text="{Binding Message}" Foreground="{DynamicResource PosTextMuted}" Margin="4,10,0,0" FontSize="13.5"/>
</Grid> </Grid>
<!-- ПРАВО: чек + оплата --> <!-- ПРАВО: чек + оплата -->

View file

@ -36,6 +36,10 @@ public record ProductSyncDto
public required bool IsMarked { get; init; } public required bool IsMarked { get; init; }
/// <summary>true — товар архивный, на кассе не продаётся, оставлен для отчётов.</summary> /// <summary>true — товар архивный, на кассе не продаётся, оставлен для отчётов.</summary>
public required bool IsArchived { get; init; } public required bool IsArchived { get; init; }
/// <summary>Товар в «Избранном» кассы (плитка на экране продажи).</summary>
public bool IsFavorite { get; init; }
/// <summary>Порядок плитки в «Избранном».</summary>
public int FavoriteOrder { get; init; }
public DateTime UpdatedAt { get; init; } public DateTime UpdatedAt { get; init; }
} }

View file

@ -436,3 +436,31 @@ public void Cashier_not_assigned_to_device_point_is_blocked()
=> DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { B }) => DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { B })
.Should().Be(DeviceRegistration.Decision.NotAssigned); .Should().Be(DeviceRegistration.Decision.NotAssigned);
} }
public class PosFavoritesTests
{
private static ProductSyncDto P(Guid id, string name, bool fav, int order) => new()
{
Id = id, Name = name, Barcodes = Array.Empty<string>(), UnitCode = "шт", Packaging = 1,
VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false,
IsFavorite = fav, FavoriteOrder = order, UpdatedAt = DateTime.UtcNow,
};
[Fact]
public void GetFavorites_returns_only_favorites_in_order()
{
using var s = PosStore.InMemory();
var a = Guid.NewGuid(); var b = Guid.NewGuid(); var c = Guid.NewGuid();
s.ApplySync(new PosSyncResponse
{
ServerTime = DateTime.UtcNow,
Products = new[] { P(a, "Обычный", false, 0), P(b, "Хлеб", true, 2), P(c, "Молоко", true, 1) },
Prices = Array.Empty<PriceSyncDto>(), Stocks = Array.Empty<StockSyncDto>(),
Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
});
var favs = s.GetFavorites();
favs.Select(f => f.Name).Should().Equal("Молоко", "Хлеб"); // по FavoriteOrder 1,2
favs.Should().OnlyContain(f => f.Name != "Обычный");
}
}