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>
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 129 KiB |
BIN
docs/pos-screenshots/11-shell-dark-1366x768.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/pos-screenshots/11-shell-dark-1920x1080.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
|
|
@ -1300,5 +1300,7 @@ private static void Apply(Product e, ProductInput i)
|
|||
}
|
||||
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
||||
e.ImageUrl = i.ImageUrl;
|
||||
e.IsFavorite = i.IsFavorite;
|
||||
e.FavoriteOrder = i.FavoriteOrder;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@ orderby l.SortOrder
|
|||
// Product не имеет IsArchived в текущей доменной модели; всегда false.
|
||||
// Когда добавим архив — заполняется здесь, без правки контракта.
|
||||
IsArchived = false,
|
||||
IsFavorite = x.p.IsFavorite,
|
||||
FavoriteOrder = x.p.FavoriteOrder,
|
||||
UpdatedAt = x.stamp,
|
||||
}).ToList();
|
||||
|
||||
|
|
|
|||
|
|
@ -100,4 +100,5 @@ public record ProductInput(
|
|||
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
|
||||
[StringLength(1000)] string? ImageUrl = null,
|
||||
IReadOnlyList<ProductPriceInput>? Prices = null,
|
||||
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
||||
IReadOnlyList<ProductBarcodeInput>? Barcodes = null,
|
||||
bool IsFavorite = false, int FavoriteOrder = 0);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,12 @@ public class Product : TenantEntity
|
|||
/// вне сезона), архивный — навсегда (списан).</summary>
|
||||
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<ProductBarcode> Barcodes { get; set; } = [];
|
||||
public ICollection<ProductImage> Images { get; set; } = [];
|
||||
|
|
|
|||
|
|
@ -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"";
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,8 @@ private void InitSchema()
|
|||
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,
|
||||
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 INDEX IF NOT EXISTS ix_barcodes_code ON barcodes(Code);
|
||||
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,
|
||||
CreatedAt TEXT NOT NULL, Error 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с вместо десятков секунд (команда на строку).
|
||||
var up = _cn.CreateCommand(); up.Transaction = tx;
|
||||
up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt)
|
||||
VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up)
|
||||
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,$fav,$favo)
|
||||
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;";
|
||||
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$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", "$fav", "$favo");
|
||||
up.Prepare();
|
||||
var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id";
|
||||
var delP = AddParams(delBc, "$id"); delBc.Prepare();
|
||||
|
|
@ -79,6 +89,7 @@ public void ApplySync(PosSyncResponse sync)
|
|||
upP["$ve"].Value = p.VatEnabled ? 1 : 0;
|
||||
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["$fav"].Value = p.IsFavorite ? 1 : 0; upP["$favo"].Value = p.FavoriteOrder;
|
||||
up.ExecuteNonQuery();
|
||||
delP["$id"].Value = idStr; delBc.ExecuteNonQuery();
|
||||
foreach (var bc in p.Barcodes)
|
||||
|
|
@ -123,6 +134,18 @@ public int ProductCount()
|
|||
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)
|
||||
{
|
||||
term = (term ?? string.Empty).Trim();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ public sealed class PosProduct
|
|||
public required bool VatEnabled { get; init; }
|
||||
public required decimal Price { get; init; }
|
||||
public bool IsArchived { get; init; }
|
||||
public bool IsFavorite { get; init; }
|
||||
public bool IsWeighed => Packaging == PosPackaging.Weight;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ public override void OnFrameworkInitializationCompleted()
|
|||
else
|
||||
{
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public static int Run(string[] args)
|
|||
new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }),
|
||||
new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()),
|
||||
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) };
|
||||
|
|
@ -80,6 +81,13 @@ private static Control ShellOnline()
|
|||
var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
{
|
||||
pvm.OnCancel = ClosePayment;
|
||||
|
|
@ -365,6 +377,9 @@ public partial class SaleViewModel : ViewModelBase
|
|||
private readonly Cart _cart = new();
|
||||
private readonly Stack<CartSnapshot> _parked = 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 _shiftNumber = "С-2026-000001";
|
||||
|
|
@ -385,7 +400,17 @@ public partial class SaleViewModel : ViewModelBase
|
|||
public Action<CheckoutService.CommitResult>? SaleCommitted;
|
||||
|
||||
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()
|
||||
{
|
||||
|
|
@ -531,6 +556,13 @@ public static SaleViewModel Sample()
|
|||
vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2);
|
||||
vm.ItemCount = vm.Lines.Count;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
<TextBlock Text="{Binding QueueBadge}" IsVisible="{Binding QueueBadge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</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"/>
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Right">
|
||||
<TextBlock Text="{Binding CashierName}" Foreground="{DynamicResource PosText}" FontSize="14" FontWeight="SemiBold" HorizontalAlignment="Right"/>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
x:DataType="vm:SaleViewModel">
|
||||
<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">
|
||||
|
|
@ -20,8 +20,30 @@
|
|||
</Grid>
|
||||
</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 Grid.Row="0" ColumnDefinitions="*,170,120,44" Height="40" Margin="18,6,10,0">
|
||||
<TextBlock Text="Товар" Foreground="{DynamicResource PosTextMuted}" FontSize="13" VerticalAlignment="Center"/>
|
||||
|
|
@ -74,7 +96,7 @@
|
|||
</Grid>
|
||||
</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>
|
||||
|
||||
<!-- ПРАВО: чек + оплата -->
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ public record ProductSyncDto
|
|||
public required bool IsMarked { get; init; }
|
||||
/// <summary>true — товар архивный, на кассе не продаётся, оставлен для отчётов.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -436,3 +436,31 @@ public void Cashier_not_assigned_to_device_point_is_blocked()
|
|||
=> DeviceRegistration.Evaluate(A, canManagePoints: false, new[] { B })
|
||||
.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 != "Обычный");
|
||||
}
|
||||
}
|
||||
|
|
|
|||