Compare commits

...

4 commits

Author SHA1 Message Date
nns 85e486abc7 docs(pos-ui): реальное окно Windows 11 — новая светлая тема
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:53:57 +05:00
nns 29ef76990e fix(pos-ui): выравнивание по замечаниям (кнопки, клавиатура, модалка, правая панель)
- Текст во ВСЕХ кнопках по центру: HorizontalAlignment=Stretch + Horizontal/
  VerticalContentAlignment=Center в стилях Button.pos/key/seg (раньше key/seg не
  наследовали центровку → цифры/«−/+»/«Сброс»/«⌫» липли влево-вверх).
- Клавиатура оплаты — ровная сетка 3×4 одинаковых крупных клавиш (72px), цифры по центру.
- Быстрые суммы и сегменты «Нал/Карта/Смешанная» — UniformGrid одинаковой ширины;
  сегменты слитным переключателем; «Без сдачи» не обрезается.
- Модалка оплаты по высоте содержимого, по центру, на затемнённой подложке (#0F1F17 ~40%).
- Правая панель: ровная 2-кол. сетка вторичных кнопок; добавлены «Возврат · F9» и
  «Внесение/Выплата · F10»; «Очистить · Esc» — красный контур во всю ширину; без пустот
  (блок компактный, Оплатить сразу под ИТОГО).
- Строка корзины: степпер 44×44 с количеством по центру, отступ до иконки удаления.
- Экран продажи снимается С ОБОЛОЧКОЙ (верхняя полоса + капсулы горячих клавиш);
  «Избранное» — шеврон ▾/▸. --uitest OK, 44 unit зелёные.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:46:17 +05:00
nns ac55da8d06 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>
2026-10-08 13:35:56 +05:00
nns 7a93c1941d feat(pos-ui): светлая стильная тема по умолчанию (Square/Loyverse-подход)
По просьбе владельца («фон мрачный — сделай светлее, стильнее, удобнее»).
- Дизайн-система на токенах: Styles/Tokens.axaml (ThemeDictionaries Light/Dark) +
  Styles/PosStyles.axaml (карточки/кнопки/чипы/клавиши/сегменты). RequestedThemeVariant=Light.
  Палитра: фон #F4F6F5, карточки белые, бренд-зелёный #16A34A, опасное — красный контур,
  предупреждение — янтарь. Inter, крупная иерархия, скругления 12–14, мягкие тени, сетка 8px.
- Экран продажи: белая верхняя полоса (лого/точка/кассир + чипы смены и связи), поиск со
  скан-иконкой, корзина-карточки со степпером [−]N[+] и удалением, скидка в строке зелёным,
  пустая корзина-заглушка, правая карточка чека с крупным зелёным ИТОГО и «Оплатить · F12»,
  вторичные кнопки-контуры, капсулы горячих клавиш.
- Оплата (светлое модальное): сегменты Наличные/Карта/Смешанная, быстрые суммы, цифровая
  клавиатура, крупная «Сдача».
- Вход (логин + PIN-пад), смена/Z-отчёт (разница цветом), возврат (степперы) — в том же стиле.
- Кнопки ≥48px, клавиши крупные. Скорость не ухудшилась (старт 149мс, скан 11мс, поиск 33мс).
  --uitest OK, 43 unit зелёные. Галерея: разделы «Стало»/«Было».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:26:07 +05:00
71 changed files with 953 additions and 284 deletions

50
docs/pos-design.md Normal file
View file

@ -0,0 +1,50 @@
# Касса food-market — дизайн-система (светлая тема)
Подход: чисто, светло, крупно, ничего лишнего (как Square POS / Loyverse / Poster).
Всё строится на токенах из одного места — `Styles/Tokens.axaml` (ResourceDictionary).
Тёмная тема — те же токены, другой набор значений (опция в настройках, не по умолчанию).
## Палитра (токены)
| Токен | Светлая | Назначение |
|---|---|---|
| `PosBg` | #F4F6F5 | фон приложения |
| `PosCard` | #FFFFFF | карточки, полосы, модальные |
| `PosBorder` | #E3E8E6 | рамки, разделители |
| `PosText` | #0F1F17 | основной текст |
| `PosтатText2` / `PosTextMuted` | #5F6F68 | вторичный текст |
| `PosBrand` | #16A34A | бренд/главное действие (зелёный Food Market) |
| `PosBrandHover` | #15803D | hover |
| `PosBrandPressed` | #166534 | pressed |
| `PosDanger` | #DC2626 | опасное (Очистить/Удалить) — только контур |
| `PosWarn` | #D97706 | предупреждение (смена закрыта, нет связи) — янтарный |
| `PosBrandSoft` | #DCFCE7 | мягкий зелёный фон (чипы, успех) |
| `PosWarnSoft` | #FEF3C7 | мягкий янтарный фон |
Контраст текста к фону ≥ WCAG AA (PosText на PosCard/PosBg — ~14:1; PosTextMuted — ~5:1).
## Типографика (Inter)
- ИТОГО: 44–56 pt, Bold.
- Суммы строк: 18–20 pt, SemiBold, **tabular** (моноширинные цифры — колонки не прыгают).
- Названия товаров: 17–18 pt.
- Служебное/подписи: 13–14 pt, PosTextMuted.
- Моноширинные цифры: `<TextBlock Classes="num">` → FontFeatures "tnum=1" / FontFamily с tabular.
## Форма и ритм
- Скругления: карточки/кнопки 12–14, чипы 999 (капсула).
- Тени: мягкие у карточек (BoxShadow 0 1 3 #00000014, 0 4 12 #00000010).
- Сетка отступов: шаг 8 px (8/12/16/24).
- Кнопки: высота ≥ 48 px (сенсор ≥ 44). Главная — заливка PosBrand; вторичные —
контур PosBorder на белом; опасная — контур PosDanger (без заливки).
## Компоненты (классы стилей)
- `Button.primary` — зелёная заливка, белый текст, крупная.
- `Button.ghost` — белая, контур PosBorder, текст PosText (вторичные действия).
- `Button.danger` — белая, контур PosDanger, текст PosDanger (Очистить/Удалить).
- `Button.key` — клавиша цифровой клавиатуры/степпера.
- `Border.card` — белая карточка со скруглением и тенью.
- `Border.chip` / `Border.chip.ok` / `.warn` — капсулы статуса.
- `TextBox.search` — поле поиска/скана с иконкой.
## Тёмная тема (опция)
Те же имена токенов, тёмные значения (PosBg #0F1713, PosCard #15201B, PosText #E8EFEA…),
переключается через `RequestedThemeVariant` + подмену словаря. По умолчанию — светлая.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 108 KiB

39
docs/pos-ui-progress.md Normal file
View file

@ -0,0 +1,39 @@
# Новый внешний вид кассы — прогресс
Задача владельца (08.10.2026): «визуально выглядит ужасно, фон мрачный — сделай
интерфейс более стильным, светлым, удобным». Светлая тема по умолчанию,
ориентиры Square/Loyverse/Poster. Скорость не ухудшать.
## Замеры скорости (до/после)
| Метрика | До (dark) | После (light) |
|---|---|---|
| Холодный старт (процесс→логика), Linux | 146 мс | 149 мс (в пределах шума) |
| Поиск по штрихкоду | 13,9 мс | 11,2 мс |
| Поиск по имени на 30 000 | 41,9 мс | 33,2 мс |
Скорость не ухудшилась (светлая тема — только ресурсы/стили, логика та же).
## План
1. [x] Токены светлой темы в `Styles/Tokens.axaml` (ThemeDictionaries Light/Dark) + docs/pos-design.md.
2. [x] Светлая тема по умолчанию (`RequestedThemeVariant=Light`); тёмная — те же токены (вариант Dark).
3. [x] Экран продажи: белая верхняя полоса (лого/точка/кассир/чипы смены+связи),
поиск со скан-иконкой, корзина-карточки со степпером [−]N[+] и удалением,
белая карточка чека с крупным зелёным ИТОГО и «Оплатить · F12», вторичные
кнопки-контуры, пустая корзина-заглушка, капсулы горячих клавиш.
4. [x] Оплата (светлое модальное): сегменты Нал/Карта/Смешанная, быстрые суммы
(без сдачи/1000/2000/5000/10000/20000), цифровая клавиатура, крупная «Сдача».
5. [x] Остальные экраны в стиле: вход (логин + PIN-пад), регистрация/выбор точки,
смена/Z-отчёт (разница цветом), возврат (степперы). Нет связи — чип в шапке.
6. [ ] Избранные товары: сервер (флаг/порядок у товара) + миграция + плитки UI.
7. [x] Удобство: кнопки ≥48px, клавиши крупные; фокус в поле скана; адаптив 1366/1920.
8. [~] Скриншоты «Было/Стало» 1920×1080 + 1366×768 (галерея с разделами); реальное окно Windows 11 — в конце.
9. [x] --uitest OK; unit 43 зелёные; скорость не хуже. Smoke Linux/Windows — в конце.
10. [~] Коммиты в main; push (по ходу).
Осталось: избранные товары (п.6), тёмная тема как переключатель в настройках,
реальное окно Windows 11, smoke Win/Linux, итоговый push.
НЕ трогать: прод, shared-postgres, global.json, gateway nginx, sklad, другие VM.
## Журнал
- 08.10 — старт; сохранены «до»-скриншоты (22), замерен baseline; планы зафиксированы.

View file

@ -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;
}
}

View file

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

View file

@ -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);

View file

@ -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; } = [];

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

View file

@ -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;
}

View file

@ -2,47 +2,19 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:foodmarket.Pos"
x:Class="foodmarket.Pos.App"
RequestedThemeVariant="Dark">
RequestedThemeVariant="Light">
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://foodmarket.Pos/Styles/Tokens.axaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<!-- Крупный кассовый стиль: большие кнопки, читаемые цифры, без анимаций. -->
<Style Selector="Button.pos">
<Setter Property="Height" Value="56" />
<Setter Property="FontSize" Value="18" />
<Setter Property="Padding" Value="12,0" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="#2d3748" />
<Setter Property="Foreground" Value="#f7fafc" />
<!-- Текст строго по центру кнопки (и по вертикали, и по горизонтали). -->
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<!-- Содержимое кнопок кассы не обрезаем: при нехватке ширины переносим,
текст по центру. Избавляет от «Esc · Очистит…». -->
<Style Selector="Button.pos /template/ ContentPresenter">
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.pos TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="#16a34a" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="#dc2626" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="TextBox.pos">
<Setter Property="FontSize" Value="20" />
<Setter Property="Height" Value="52" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<StyleInclude Source="avares://foodmarket.Pos/Styles/PosStyles.axaml"/>
</Application.Styles>
</Application>

View file

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

View file

@ -0,0 +1,168 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ── Карточки ─────────────────────────────────────────────────── -->
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="BorderBrush" Value="{DynamicResource PosBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="14"/>
<Setter Property="BoxShadow" Value="0 1 3 0 #14000000, 0 6 16 0 #0D000000"/>
</Style>
<!-- ── Чипы статуса (капсулы) ───────────────────────────────────── -->
<Style Selector="Border.chip">
<Setter Property="CornerRadius" Value="999"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Background" Value="{DynamicResource PosSubtle}"/>
</Style>
<Style Selector="Border.chip > TextBlock">
<Setter Property="Foreground" Value="{DynamicResource PosTextMuted}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style Selector="Border.chip.ok">
<Setter Property="Background" Value="{DynamicResource PosBrandSoft}"/>
</Style>
<Style Selector="Border.chip.ok > TextBlock"><Setter Property="Foreground" Value="{DynamicResource PosBrand}"/></Style>
<Style Selector="Border.chip.warn">
<Setter Property="Background" Value="{DynamicResource PosWarnSoft}"/>
</Style>
<Style Selector="Border.chip.warn > TextBlock"><Setter Property="Foreground" Value="{DynamicResource PosWarn}"/></Style>
<!-- ── Кнопки ───────────────────────────────────────────────────── -->
<Style Selector="Button.pos">
<Setter Property="Height" Value="50"/>
<Setter Property="MinHeight" Value="48"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="16,0"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="Foreground" Value="{DynamicResource PosText}"/>
<Setter Property="BorderBrush" Value="{DynamicResource PosBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
</Style>
<Style Selector="Button.pos /template/ ContentPresenter">
<Setter Property="Transitions">
<Transitions><BrushTransition Property="Background" Duration="0:0:0.08"/></Transitions>
</Setter>
</Style>
<Style Selector="Button.pos TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="TextAlignment" Value="Center"/>
</Style>
<!-- Главная зелёная -->
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource PosBrand}"/>
<Setter Property="Foreground" Value="{DynamicResource PosBrandText}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontWeight" Value="Bold"/>
</Style>
<Style Selector="Button.primary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PosBrandHover}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource PosBrandText}"/>
</Style>
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PosBrandPressed}"/>
</Style>
<!-- Вторичная (контур) -->
<Style Selector="Button.ghost">
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="Foreground" Value="{DynamicResource PosText}"/>
</Style>
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PosSubtle}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource PosText}"/>
</Style>
<!-- Опасная (красный контур, без заливки) -->
<Style Selector="Button.danger">
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="Foreground" Value="{DynamicResource PosDanger}"/>
<Setter Property="BorderBrush" Value="{DynamicResource PosDanger}"/>
</Style>
<Style Selector="Button.danger:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PosDangerSoft}"/>
<Setter Property="TextElement.Foreground" Value="{DynamicResource PosDanger}"/>
</Style>
<!-- Клавиша клавиатуры/степпер -->
<Style Selector="Button.key">
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="Foreground" Value="{DynamicResource PosText}"/>
<Setter Property="FontSize" Value="22"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="BorderBrush" Value="{DynamicResource PosBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.key /template/ ContentPresenter">
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.key:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PosSubtle}"/>
</Style>
<!-- Сегмент переключателя (выбранный — зелёный) -->
<Style Selector="Button.seg">
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="Foreground" Value="{DynamicResource PosText}"/>
<Setter Property="Height" Value="52"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.seg /template/ ContentPresenter">
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.seg.on">
<Setter Property="Background" Value="{DynamicResource PosBrand}"/>
<Setter Property="Foreground" Value="{DynamicResource PosBrandText}"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<Style Selector="Button.seg.on /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PosBrand}"/>
</Style>
<!-- ── Поля ввода ──────────────────────────────────────────────── -->
<Style Selector="TextBox.pos">
<Setter Property="FontSize" Value="17"/>
<Setter Property="Height" Value="52"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="Foreground" Value="{DynamicResource PosText}"/>
<Setter Property="BorderBrush" Value="{DynamicResource PosBorder}"/>
</Style>
<Style Selector="TextBox.search">
<Setter Property="FontSize" Value="19"/>
<Setter Property="Height" Value="58"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="CornerRadius" Value="14"/>
<Setter Property="Background" Value="{DynamicResource PosCard}"/>
<Setter Property="Foreground" Value="{DynamicResource PosText}"/>
<Setter Property="BorderBrush" Value="{DynamicResource PosBorder}"/>
</Style>
<!-- Капсулы горячих клавиш -->
<Style Selector="Border.hotkey">
<Setter Property="Background" Value="{DynamicResource PosSubtle}"/>
<Setter Property="BorderBrush" Value="{DynamicResource PosBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="9,4"/>
</Style>
</Styles>

View file

@ -0,0 +1,46 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.ThemeDictionaries>
<!-- ── Светлая тема (по умолчанию) ─────────────────────────────── -->
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="PosBg" Color="#F4F6F5"/>
<SolidColorBrush x:Key="PosCard" Color="#FFFFFF"/>
<SolidColorBrush x:Key="PosSubtle" Color="#F0F3F1"/>
<SolidColorBrush x:Key="PosBorder" Color="#E3E8E6"/>
<SolidColorBrush x:Key="PosText" Color="#0F1F17"/>
<SolidColorBrush x:Key="PosTextMuted" Color="#5F6F68"/>
<SolidColorBrush x:Key="PosBrand" Color="#16A34A"/>
<SolidColorBrush x:Key="PosBrandHover" Color="#15803D"/>
<SolidColorBrush x:Key="PosBrandPressed" Color="#166534"/>
<SolidColorBrush x:Key="PosBrandText" Color="#FFFFFF"/>
<SolidColorBrush x:Key="PosBrandSoft" Color="#DCFCE7"/>
<SolidColorBrush x:Key="PosDanger" Color="#DC2626"/>
<SolidColorBrush x:Key="PosDangerSoft" Color="#FEE2E2"/>
<SolidColorBrush x:Key="PosWarn" Color="#B45309"/>
<SolidColorBrush x:Key="PosWarnSoft" Color="#FEF3C7"/>
<SolidColorBrush x:Key="PosScrim" Color="#33203028"/>
</ResourceDictionary>
<!-- ── Тёмная тема (опция, те же токены) ───────────────────────── -->
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="PosBg" Color="#0F1713"/>
<SolidColorBrush x:Key="PosCard" Color="#15201B"/>
<SolidColorBrush x:Key="PosSubtle" Color="#1B2821"/>
<SolidColorBrush x:Key="PosBorder" Color="#2A3A31"/>
<SolidColorBrush x:Key="PosText" Color="#E8EFEA"/>
<SolidColorBrush x:Key="PosTextMuted" Color="#9BAAA2"/>
<SolidColorBrush x:Key="PosBrand" Color="#22C55E"/>
<SolidColorBrush x:Key="PosBrandHover" Color="#16A34A"/>
<SolidColorBrush x:Key="PosBrandPressed" Color="#15803D"/>
<SolidColorBrush x:Key="PosBrandText" Color="#06140C"/>
<SolidColorBrush x:Key="PosBrandSoft" Color="#13331F"/>
<SolidColorBrush x:Key="PosDanger" Color="#F87171"/>
<SolidColorBrush x:Key="PosDangerSoft" Color="#3A1A1A"/>
<SolidColorBrush x:Key="PosWarn" Color="#FBBF24"/>
<SolidColorBrush x:Key="PosWarnSoft" Color="#33280D"/>
<SolidColorBrush x:Key="PosScrim" Color="#66000000"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>

View file

@ -33,14 +33,15 @@ public static int Run(string[] args)
new("01-login", "Вход кассира (адрес сервера, логин, пароль)", () => new LoginView { DataContext = LoginViewModel.Sample() }),
new("01b-login-pin", "Быстрый вход по PIN (зарегистрированное устройство)", () => new LoginView { DataContext = LoginViewModel.SamplePin() }),
new("02-shift-open", "Смена: Z-отчёт/состояние смены", () => new ShiftView { DataContext = ShiftViewModel.Sample() }),
new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования", () => new SaleView { DataContext = new SaleViewModel() }),
new("04-sale-items", "Продажа: чек с товарами (вкл. весовой)", () => new SaleView { DataContext = SaleViewModel.Sample() }),
new("05-payment-cash", "Оплата наличными со сдачей", () => new PaymentView { DataContext = PaymentViewModel.SampleCash() }),
new("06-payment-mixed", "Оплата смешанная (нал+карта)", () => new PaymentView { DataContext = PaymentViewModel.SampleMixed() }),
new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования (с оболочкой)", () => ShellWith(EmptySale())),
new("04-sale-items", "Продажа: чек с товарами + избранное (с оболочкой)", () => new MainWindow { DataContext = new MainWindowViewModel() }),
new("05-payment-cash", "Оплата наличными со сдачей", () => PayScrim(PaymentViewModel.SampleCash())),
new("06-payment-mixed", "Оплата смешанная (нал+карта)", () => PayScrim(PaymentViewModel.SampleMixed())),
new("07-return", "Возврат по чеку (выбор позиций)", () => new ReturnView { DataContext = ReturnViewModel.Sample() }),
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) };
@ -70,6 +71,26 @@ public static int Run(string[] args)
return 0;
}
private static SaleViewModel EmptySale() => new();
private static Control ShellWith(SaleViewModel sale)
{
var vm = new MainWindowViewModel();
vm.CurrentPage = sale;
return new MainWindow { DataContext = vm };
}
/// <summary>Модальная оплата на затемнённой подложке (как в оболочке):
/// светлый фон → полупрозрачная тёмная подложка → белое модальное окно.</summary>
private static Control PayScrim(PaymentViewModel pay)
=> new Border
{
Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#F4F6F5")),
Child = new Border
{
Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#660F1F17")),
Child = new PaymentView { DataContext = pay },
},
};
private static Control ShellOffline()
{
var vm = new MainWindowViewModel { IsOnline = false, QueueCount = 3, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" };
@ -80,6 +101,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)
{
@ -90,11 +118,32 @@ private static void WriteGallery(string dir, List<(string file, string caption)>
".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:16px;padding:20px}" +
"figure{margin:0;background:#1e293b;border:1px solid #334155;border-radius:10px;overflow:hidden}" +
"img{width:100%;display:block;background:#000;cursor:zoom-in}figcaption{padding:8px 10px;color:#cbd5e1;font-size:13px}</style></head><body>");
sb.Append($"<header><h1>Касса food-market — скриншоты процессов</h1><div style=\"color:#94a3b8\">рендер headless (Avalonia/Skia, Linux) — 1920×1080 и 1366×768. Кадров: {shots.Count}</div></header><div class=\"grid\">");
sb.Append($"<header><h1>Касса food-market — скриншоты процессов</h1><div style=\"color:#94a3b8\">Светлая тема (Avalonia/Skia, headless, Linux) — 1920×1080 и 1366×768. Кадров: {shots.Count}</div></header>");
sb.Append("<header style=\"background:#eef2f0;color:#0f1f17\"><h1 style=\"color:#16a34a\">Стало (светлая тема)</h1></header>");
sb.Append("<div class=\"grid\">");
foreach (var (file, caption) in shots)
sb.Append($"<figure><a href=\"{file}\" target=\"_blank\"><img src=\"{file}\" loading=\"lazy\"></a><figcaption>{System.Net.WebUtility.HtmlEncode(caption)}</figcaption></figure>");
sb.Append("</div>");
// Раздел «Было» — прежняя тёмная тема (если сохранена в before/).
var beforeDir = Path.Combine(dir, "before");
if (Directory.Exists(beforeDir))
{
var befs = Directory.GetFiles(beforeDir, "*.png").OrderBy(f => f).ToList();
if (befs.Count > 0)
{
sb.Append("<header style=\"background:#1e293b;margin-top:8px\"><h1 style=\"color:#e2e8f0\">Было (тёмная тема)</h1>" +
"<div style=\"color:#94a3b8\">для сравнения — прежний мрачный интерфейс</div></header>");
sb.Append("<div class=\"grid\" style=\"background:#0f172a\">");
foreach (var b in befs)
{
var rel = "before/" + Path.GetFileName(b);
sb.Append($"<figure style=\"background:#1e293b;border-color:#334155\"><a href=\"{rel}\" target=\"_blank\"><img src=\"{rel}\" loading=\"lazy\"></a><figcaption style=\"color:#cbd5e1\">{System.Net.WebUtility.HtmlEncode(Path.GetFileNameWithoutExtension(b))}</figcaption></figure>");
}
sb.Append("</div>");
}
}
// Раздел «Windows 11» — реальные скриншоты окна на Windows (если есть).
var winDir = Path.Combine(dir, "windows");
if (Directory.Exists(winDir))

View file

@ -24,19 +24,24 @@ public partial class MainWindowViewModel : ViewModelBase
[ObservableProperty] private object? _currentPage;
[ObservableProperty] private string _cashierName = "—";
[ObservableProperty] private string _shiftNumber = "смена не открыта";
[ObservableProperty] private bool _isShiftOpen;
[ObservableProperty] private string _pointName = "Магазин";
[ObservableProperty] private bool _isOnline = true;
[ObservableProperty] private int _queueCount;
[ObservableProperty] private PaymentViewModel? _payment;
[ObservableProperty] private bool _isPaymentOpen;
[ObservableProperty] private string? _updateMessage;
public string NetBadge => IsOnline ? "● на связи" : "○ нет связи";
public string QueueBadge => QueueCount > 0 ? $"{QueueCount} чек(ов) в очереди" : "очередь пуста";
public string NetBadge => IsOnline ? "На связи" : "Нет связи";
public string ShiftBadge => IsShiftOpen ? $"Смена {ShiftNumber}" : "Смена закрыта";
public string QueueBadge => QueueCount > 0 ? $"· {QueueCount} в очереди" : "";
partial void OnIsOnlineChanged(bool value) { OnPropertyChanged(nameof(NetBadge)); }
partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); }
partial void OnIsShiftOpenChanged(bool value) => OnPropertyChanged(nameof(ShiftBadge));
partial void OnShiftNumberChanged(string value) => OnPropertyChanged(nameof(ShiftBadge));
/// <summary>Конструктор для дизайнера/скриншотов (демо-данные).</summary>
public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000001"; }
public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000001"; IsShiftOpen = true; PointName = "Магазин №1 · ул. Абая 10"; }
public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); }
@ -50,7 +55,12 @@ private void StartSession()
{
CashierName = _svc!.Session?.CashierName ?? "кассир";
QueueCount = _svc.Store.PendingCount();
_sale = new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() };
_sale = new SaleViewModel(_svc)
{
PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus(),
NavReturnRequested = () => ShowReturnCommand.Execute(null),
NavShiftRequested = () => ShowShiftCommand.Execute(null),
};
CurrentPage = _sale;
StartSyncLoop();
_ = CheckForUpdateAsync();
@ -86,9 +96,21 @@ private static string LocalVersion()
private void RefreshStatus() { if (_svc is not null) QueueCount = _svc.Store.PendingCount(); }
[RelayCommand] private void ShowSale() { CurrentPage = _sale ??= (_svc is null ? SaleViewModel.Sample() : new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() }); }
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; } };
[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;
@ -360,6 +382,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";
@ -369,14 +394,34 @@ public partial class SaleViewModel : ViewModelBase
[ObservableProperty] private decimal _total;
[ObservableProperty] private int _itemCount;
[ObservableProperty] private SaleLineVm? _selectedLine;
public bool IsCartEmpty => ItemCount == 0;
public bool HasAnyDiscount => Discount > 0m;
partial void OnItemCountChanged(int value) => OnPropertyChanged(nameof(IsCartEmpty));
partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(HasAnyDiscount));
/// <summary>Поднять модальную оплату (обрабатывает оболочка).</summary>
public Action<PaymentViewModel>? PaymentRequested;
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
public Action<CheckoutService.CommitResult>? SaleCommitted;
/// <summary>Переходы к возврату/смене (обрабатывает оболочка — те же, что F9/F7/F10).</summary>
public Action? NavReturnRequested;
public Action? NavShiftRequested;
[RelayCommand] private void GoReturn() => NavReturnRequested?.Invoke();
[RelayCommand] private void GoShift() => NavShiftRequested?.Invoke();
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()
{
@ -433,6 +478,17 @@ public void SetLineQuantity(SaleLineVm line, decimal qty)
Recalc();
}
[RelayCommand] private void IncLine(SaleLineVm line) { if (line.Model is { } m) SetLineQuantity(line, m.Quantity + 1m); }
[RelayCommand] private void DecLine(SaleLineVm line) { if (line.Model is { } m) SetLineQuantity(line, m.Quantity - 1m); }
[RelayCommand]
private void RemoveLine(SaleLineVm line)
{
if (line.Model is { } m) _cart.Remove(m);
Lines.Remove(line);
if (ReferenceEquals(SelectedLine, line)) SelectedLine = Lines.FirstOrDefault();
Recalc();
}
public void SetCartDiscount(decimal amount) { _cart.SetCartDiscount(amount); Recalc(); Message = $"Скидка на чек: {amount:N0} ₸"; }
[RelayCommand]
@ -511,6 +567,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;
}
}
@ -524,16 +587,35 @@ public partial class PaymentViewModel : ViewModelBase
public decimal Change => PaymentMath.Compute(Total, PaidCash, PaidCard).Change;
public decimal Shortfall => PaymentMath.Compute(Total, PaidCash, PaidCard).Shortfall;
public bool Sufficient => PaymentMath.Compute(Total, PaidCash, PaidCard).Sufficient;
public bool IsCash => Method == "Наличные";
public bool IsCard => Method == "Карта";
public bool IsMixed => Method == "Смешанная";
partial void OnPaidCashChanged(decimal value) => RaisePay();
partial void OnPaidCardChanged(decimal value) => RaisePay();
partial void OnTotalChanged(decimal value) => RaisePay();
partial void OnMethodChanged(string value) { OnPropertyChanged(nameof(IsCash)); OnPropertyChanged(nameof(IsCard)); OnPropertyChanged(nameof(IsMixed)); }
private void RaisePay() { OnPropertyChanged(nameof(Change)); OnPropertyChanged(nameof(Shortfall)); OnPropertyChanged(nameof(Sufficient)); }
/// <summary>Вызывается при подтверждении оплаты (cash, card, change).</summary>
public Func<decimal, decimal, decimal, Task>? OnConfirm;
public Action? OnCancel;
[RelayCommand] private void ExactCash() { PaidCash = Total; PaidCard = 0m; Method = "Наличные"; }
[RelayCommand] private void ExactCash() { PaidCash = Total; PaidCard = 0m; }
[RelayCommand] private void SelectCash() { Method = "Наличные"; PaidCard = 0m; }
[RelayCommand] private void SelectCard() { Method = "Карта"; PaidCard = Total; PaidCash = 0m; }
[RelayCommand] private void SelectMixed() { Method = "Смешанная"; }
/// <summary>Быстрая сумма наличных: «Без сдачи» (ровно) или номинал.</summary>
[RelayCommand] private void QuickCash(string amount)
{
if (amount == "exact") { PaidCash = Total; return; }
if (decimal.TryParse(amount, out var a)) PaidCash = a;
}
// Экранная цифровая клавиатура для наличных (целые тенге).
[RelayCommand] private void KeyDigit(string d) { if (int.TryParse(d, out var n)) PaidCash = PaidCash * 10m + n; }
[RelayCommand] private void KeyBack() { PaidCash = Math.Floor(PaidCash / 10m); }
[RelayCommand] private void KeyClear() { PaidCash = 0m; }
[RelayCommand]
private async Task Confirm()
@ -544,8 +626,8 @@ private async Task Confirm()
[RelayCommand] private void Cancel() => OnCancel?.Invoke();
public static PaymentViewModel SampleCash() => new() { Total = 3365m, PaidCash = 5000m, Method = "Наличные" };
public static PaymentViewModel SampleMixed() => new() { Total = 3365m, PaidCash = 1365m, PaidCard = 2000m, Method = "Смешанная" };
public static PaymentViewModel SampleCash() => new() { Total = 3362m, PaidCash = 5000m, Method = "Наличные" };
public static PaymentViewModel SampleMixed() => new() { Total = 3362m, PaidCash = 1362m, PaidCard = 2000m, Method = "Смешанная" };
}
/// <summary>Смена: открыть (размен) / закрыть (факт → должно/факт/разница),
@ -568,9 +650,12 @@ public partial class ShiftViewModel : ViewModelBase
public decimal ExpectedCash => ShiftMath.ExpectedCash(OpeningFloat, SalesCash, ReturnsCash, DrawerIn, DrawerOut);
public decimal Difference => ShiftMath.CashDifference(ActualCash, ExpectedCash);
public bool DiffSurplus => Difference > 0m; // излишек
public bool DiffShortage => Difference < 0m; // недостача
public string DiffLabel => Difference == 0m ? "Сходится" : Difference > 0m ? "Излишек" : "Недостача";
partial void OnOpeningFloatChanged(decimal value) => RaiseCalc();
partial void OnActualCashChanged(decimal value) => RaiseCalc();
private void RaiseCalc() { OnPropertyChanged(nameof(ExpectedCash)); OnPropertyChanged(nameof(Difference)); }
private void RaiseCalc() { OnPropertyChanged(nameof(ExpectedCash)); OnPropertyChanged(nameof(Difference)); OnPropertyChanged(nameof(DiffSurplus)); OnPropertyChanged(nameof(DiffShortage)); OnPropertyChanged(nameof(DiffLabel)); }
/// <summary>Сообщить оболочке новый номер смены (или null — закрыта).</summary>
public Action<string?>? OnShiftChanged;

View file

@ -3,63 +3,65 @@
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.LoginView"
x:DataType="vm:LoginViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="440" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="FOOD MARKET" Foreground="#22c55e" FontWeight="Bold" FontSize="22" HorizontalAlignment="Center"/>
<TextBlock Text="Вход кассира" Foreground="#f8fafc" FontSize="18" HorizontalAlignment="Center"/>
<!-- Быстрый вход по PIN (зарегистрированное устройство) -->
<StackPanel Spacing="10" IsVisible="{Binding PinMode}">
<Border Background="#0b1220" CornerRadius="8" Height="56">
<TextBlock Text="{Binding PinMasked}" Foreground="#f8fafc" FontSize="30" LetterSpacing="6" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Grid Background="{DynamicResource PosBg}">
<Border Classes="card" Width="440" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center" CornerRadius="18">
<StackPanel Spacing="16">
<StackPanel Spacing="4" HorizontalAlignment="Center">
<Border Background="{DynamicResource PosBrand}" CornerRadius="12" Width="48" Height="48" HorizontalAlignment="Center">
<TextBlock Text="FM" Foreground="White" FontWeight="Bold" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" TextWrapping="Wrap" HorizontalAlignment="Center" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Grid ColumnDefinitions="*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" >
<Button Grid.Row="0" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="1" Command="{Binding PinDigitCommand}" CommandParameter="1"/>
<Button Grid.Row="0" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="2" Command="{Binding PinDigitCommand}" CommandParameter="2"/>
<Button Grid.Row="0" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="3" Command="{Binding PinDigitCommand}" CommandParameter="3"/>
<Button Grid.Row="1" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="4" Command="{Binding PinDigitCommand}" CommandParameter="4"/>
<Button Grid.Row="1" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="5" Command="{Binding PinDigitCommand}" CommandParameter="5"/>
<Button Grid.Row="1" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="6" Command="{Binding PinDigitCommand}" CommandParameter="6"/>
<Button Grid.Row="2" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="22" Content="7" Command="{Binding PinDigitCommand}" CommandParameter="7"/>
<Button Grid.Row="2" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="8" Command="{Binding PinDigitCommand}" CommandParameter="8"/>
<Button Grid.Row="2" Grid.Column="2" Classes="pos" Margin="3" Height="56" FontSize="22" Content="9" Command="{Binding PinDigitCommand}" CommandParameter="9"/>
<Button Grid.Row="3" Grid.Column="0" Classes="pos" Margin="3" Height="56" FontSize="18" Content="⌫" Command="{Binding PinBackCommand}"/>
<Button Grid.Row="3" Grid.Column="1" Classes="pos" Margin="3" Height="56" FontSize="22" Content="0" Command="{Binding PinDigitCommand}" CommandParameter="0"/>
<Button Grid.Row="3" Grid.Column="2" Classes="pos primary" Margin="3" Height="56" FontSize="18" Content="Войти" Command="{Binding PinLoginCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Content="Вход по логину и паролю" Command="{Binding UsePasswordCommand}"/>
<TextBlock Text="Food Market · Касса" Foreground="{DynamicResource PosText}" FontWeight="Bold" FontSize="18" HorizontalAlignment="Center" Margin="0,8,0,0"/>
<TextBlock Text="Вход кассира" Foreground="{DynamicResource PosTextMuted}" FontSize="14" HorizontalAlignment="Center"/>
</StackPanel>
<!-- PIN -->
<StackPanel Spacing="12" IsVisible="{Binding PinMode}">
<Border Background="{DynamicResource PosSubtle}" CornerRadius="12" Height="56">
<TextBlock Text="{Binding PinMasked}" Foreground="{DynamicResource PosText}" FontSize="30" LetterSpacing="8" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding Error}" Foreground="{DynamicResource PosDanger}" TextWrapping="Wrap" HorizontalAlignment="Center" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Grid ColumnDefinitions="*,*,*" RowDefinitions="Auto,Auto,Auto,Auto">
<Button Grid.Row="0" Grid.Column="0" Classes="key" Margin="4" Height="58" Content="1" Command="{Binding PinDigitCommand}" CommandParameter="1"/>
<Button Grid.Row="0" Grid.Column="1" Classes="key" Margin="4" Height="58" Content="2" Command="{Binding PinDigitCommand}" CommandParameter="2"/>
<Button Grid.Row="0" Grid.Column="2" Classes="key" Margin="4" Height="58" Content="3" Command="{Binding PinDigitCommand}" CommandParameter="3"/>
<Button Grid.Row="1" Grid.Column="0" Classes="key" Margin="4" Height="58" Content="4" Command="{Binding PinDigitCommand}" CommandParameter="4"/>
<Button Grid.Row="1" Grid.Column="1" Classes="key" Margin="4" Height="58" Content="5" Command="{Binding PinDigitCommand}" CommandParameter="5"/>
<Button Grid.Row="1" Grid.Column="2" Classes="key" Margin="4" Height="58" Content="6" Command="{Binding PinDigitCommand}" CommandParameter="6"/>
<Button Grid.Row="2" Grid.Column="0" Classes="key" Margin="4" Height="58" Content="7" Command="{Binding PinDigitCommand}" CommandParameter="7"/>
<Button Grid.Row="2" Grid.Column="1" Classes="key" Margin="4" Height="58" Content="8" Command="{Binding PinDigitCommand}" CommandParameter="8"/>
<Button Grid.Row="2" Grid.Column="2" Classes="key" Margin="4" Height="58" Content="9" Command="{Binding PinDigitCommand}" CommandParameter="9"/>
<Button Grid.Row="3" Grid.Column="0" Classes="key" Margin="4" Height="58" FontSize="18" Content="⌫" Command="{Binding PinBackCommand}"/>
<Button Grid.Row="3" Grid.Column="1" Classes="key" Margin="4" Height="58" Content="0" Command="{Binding PinDigitCommand}" CommandParameter="0"/>
<Button Grid.Row="3" Grid.Column="2" Classes="key primary" Margin="4" Height="58" FontSize="17" Content="Войти" Command="{Binding PinLoginCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
<Button Classes="pos ghost" HorizontalAlignment="Stretch" Height="42" FontSize="13" Content="Вход по логину и паролю" Command="{Binding UsePasswordCommand}"/>
</StackPanel>
<!-- Логин/пароль -->
<StackPanel Spacing="14" IsVisible="{Binding !PinMode}">
<StackPanel Spacing="4">
<TextBlock Text="Адрес сервера" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding ServerUrl}" FontSize="16"/>
<TextBlock Text="Адрес сервера" Foreground="{DynamicResource PosTextMuted}" FontSize="13"/>
<TextBox Classes="pos" Text="{Binding ServerUrl}" FontSize="15"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Логин кассира" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding Username}" FontSize="18"/>
<TextBlock Text="Логин кассира" Foreground="{DynamicResource PosTextMuted}" FontSize="13"/>
<TextBox Classes="pos" Text="{Binding Username}" FontSize="17"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="PIN / пароль" Foreground="#94a3b8"/>
<TextBox Classes="pos" Text="{Binding Pin}" PasswordChar="●" FontSize="18"/>
<TextBlock Text="PIN / пароль" Foreground="{DynamicResource PosTextMuted}" FontSize="13"/>
<TextBox Classes="pos" Text="{Binding Pin}" PasswordChar="●" FontSize="17"/>
</StackPanel>
<TextBlock Text="{Binding Error}" Foreground="#fca5a5" TextWrapping="Wrap" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="56" FontSize="18" Content="Войти" Command="{Binding LoginCommand}" IsDefault="True" IsEnabled="{Binding !Busy}" IsVisible="{Binding !RequirePointSelection}"/>
<TextBlock Text="{Binding Error}" Foreground="{DynamicResource PosDanger}" TextWrapping="Wrap" IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="54" FontSize="18" Content="Войти" Command="{Binding LoginCommand}" IsDefault="True" IsEnabled="{Binding !Busy}" IsVisible="{Binding !RequirePointSelection}"/>
<!-- Регистрация устройства: выбор точки продаж (только администратор) -->
<StackPanel Spacing="8" IsVisible="{Binding RequirePointSelection}">
<TextBlock Text="Регистрация кассы — выберите точку продаж:" Foreground="#f8fafc"/>
<ComboBox ItemsSource="{Binding Points}" SelectedItem="{Binding SelectedPoint}" HorizontalAlignment="Stretch" FontSize="16"/>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="52" Content="Зарегистрировать на этой точке" Command="{Binding ConfirmPointCommand}"/>
<TextBlock Text="Регистрация кассы — выберите точку продаж:" Foreground="{DynamicResource PosText}" FontSize="14"/>
<ComboBox ItemsSource="{Binding Points}" SelectedItem="{Binding SelectedPoint}" HorizontalAlignment="Stretch" FontSize="15"/>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="50" Content="Зарегистрировать на этой точке" Command="{Binding ConfirmPointCommand}"/>
</StackPanel>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Content="Быстрый вход по PIN" Command="{Binding UsePinCommand}" IsVisible="{Binding DeviceRegistered}"/>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="40" FontSize="13" Command="{Binding ResetDeviceCommand}" ToolTip.Tip="Сбросить регистрацию устройства. Новую точку выберет только администратор.">
<TextBlock Text="Сменить точку продаж (админ)" TextWrapping="Wrap" TextAlignment="Center"/>
</Button>
<Button Classes="pos ghost" HorizontalAlignment="Stretch" Height="40" FontSize="13" Content="Быстрый вход по PIN" Command="{Binding UsePinCommand}" IsVisible="{Binding DeviceRegistered}"/>
<Button Classes="pos ghost" HorizontalAlignment="Stretch" Height="38" FontSize="12.5" Content="Сменить точку продаж (админ)" Command="{Binding ResetDeviceCommand}" ToolTip.Tip="Сбросить регистрацию устройства. Новую точку выберет только администратор."/>
</StackPanel>
</StackPanel>
</Border>

View file

@ -3,43 +3,73 @@
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="1280" Height="800" MinWidth="1024" MinHeight="680"
Background="#0f172a" Title="Касса food-market"
Width="1366" Height="768" MinWidth="1100" MinHeight="680"
Background="{DynamicResource PosBg}" Title="Касса food-market"
WindowStartupLocation="CenterScreen">
<Panel>
<Grid RowDefinitions="54,*,34">
<!-- Статус-бар -->
<Border Grid.Row="0" Background="#1e293b" Padding="16,0">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="FOOD MARKET · Касса" FontWeight="Bold" Foreground="#22c55e" FontSize="16" VerticalAlignment="Center"/>
<Grid RowDefinitions="64,*,46">
<!-- Верхняя полоса -->
<Border Grid.Row="0" Background="{DynamicResource PosCard}" BorderBrush="{DynamicResource PosBorder}" BorderThickness="0,0,0,1" Padding="20,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<Border Background="{DynamicResource PosBrand}" CornerRadius="9" Width="34" Height="34">
<TextBlock Text="FM" Foreground="White" FontWeight="Bold" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Food Market · Касса" Foreground="{DynamicResource PosText}" FontWeight="Bold" FontSize="16"/>
<TextBlock Text="{Binding PointName}" Foreground="{DynamicResource PosTextMuted}" FontSize="12.5"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<TextBlock Text="{Binding CashierName}" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ShiftNumber}" Foreground="#93c5fd" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding UpdateMessage}" Foreground="#fde68a" VerticalAlignment="Center" HorizontalAlignment="Center"
IsVisible="{Binding UpdateMessage, Converter={x:Static ObjectConverters.IsNotNull}}" FontSize="12"/>
<TextBlock Grid.Column="3" Margin="18,0" Text="{Binding NetBadge}" VerticalAlignment="Center" Foreground="#34d399"/>
<TextBlock Grid.Column="4" Text="{Binding QueueBadge}" Foreground="#fbbf24" VerticalAlignment="Center"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<Border Classes="chip" Classes.ok="{Binding IsShiftOpen}" Classes.warn="{Binding !IsShiftOpen}">
<TextBlock Text="{Binding ShiftBadge}"/>
</Border>
<Border Classes="chip" Classes.ok="{Binding IsOnline}" Classes.warn="{Binding !IsOnline}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding NetBadge}"/>
<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"/>
<TextBlock Text="кассир" Foreground="{DynamicResource PosTextMuted}" FontSize="12" HorizontalAlignment="Right"/>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding UpdateMessage}" Foreground="{DynamicResource PosWarn}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="12.5"
IsVisible="{Binding UpdateMessage, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</Grid>
</Border>
<!-- Текущая страница -->
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}" Margin="0"/>
<ContentControl Grid.Row="1" Content="{Binding CurrentPage}"/>
<!-- Нижняя панель навигации -->
<Border Grid.Row="2" Background="#1e293b">
<StackPanel Orientation="Horizontal" Spacing="2" HorizontalAlignment="Center">
<TextBlock Text="F1 Помощь · F2 Поиск · F7 Внесение/Выплата · F9 Возврат · F10 Смена · F12 Оплата · Esc Отмена"
Foreground="#94a3b8" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
<!-- Горячие клавиши -->
<Border Grid.Row="2" Background="{DynamicResource PosCard}" BorderBrush="{DynamicResource PosBorder}" BorderThickness="0,1,0,0">
<ItemsControl VerticalAlignment="Center" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel><ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="8"/></ItemsPanelTemplate></ItemsControl.ItemsPanel>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="F1" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosTextMuted}"/><TextBlock Text="Помощь" FontSize="12" Foreground="{DynamicResource PosTextMuted}"/></StackPanel></Border>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="F2" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosTextMuted}"/><TextBlock Text="Поиск" FontSize="12" Foreground="{DynamicResource PosTextMuted}"/></StackPanel></Border>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="F7" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosTextMuted}"/><TextBlock Text="Отложить" FontSize="12" Foreground="{DynamicResource PosTextMuted}"/></StackPanel></Border>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="F9" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosTextMuted}"/><TextBlock Text="Возврат" FontSize="12" Foreground="{DynamicResource PosTextMuted}"/></StackPanel></Border>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="F10" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosTextMuted}"/><TextBlock Text="Смена" FontSize="12" Foreground="{DynamicResource PosTextMuted}"/></StackPanel></Border>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="F12" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosBrand}"/><TextBlock Text="Оплата" FontSize="12" Foreground="{DynamicResource PosBrand}"/></StackPanel></Border>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="Del" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosTextMuted}"/><TextBlock Text="Удалить" FontSize="12" Foreground="{DynamicResource PosTextMuted}"/></StackPanel></Border>
<Border Classes="hotkey"><StackPanel Orientation="Horizontal" Spacing="6"><TextBlock Text="Esc" FontWeight="Bold" FontSize="11.5" Foreground="{DynamicResource PosTextMuted}"/><TextBlock Text="Отмена" FontSize="12" Foreground="{DynamicResource PosTextMuted}"/></StackPanel></Border>
</ItemsControl>
</Border>
</Grid>
<!-- Модальная оплата поверх экрана продажи -->
<Border Background="#cc0b1220" IsVisible="{Binding IsPaymentOpen}">
<ContentControl Content="{Binding Payment}"/>
<!-- Модальная оплата -->
<Border Background="{DynamicResource PosScrim}" IsVisible="{Binding IsPaymentOpen}">
<ContentControl Content="{Binding Payment}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</Panel>
</Window>

View file

@ -3,37 +3,76 @@
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.PaymentView"
x:DataType="vm:PaymentViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="560" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="Оплата чека" FontSize="24" FontWeight="Bold" Foreground="#f8fafc" HorizontalAlignment="Center"/>
<Border Classes="card" Width="720" Padding="28" CornerRadius="18" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="16">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="К оплате" Foreground="#94a3b8" FontSize="18" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="Оплата" FontSize="22" FontWeight="Bold" Foreground="{DynamicResource PosText}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="К оплате" Foreground="{DynamicResource PosTextMuted}" FontSize="16" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="30" FontWeight="Bold"/>
</StackPanel>
</Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="140,*">
<TextBlock Text="Наличные" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Classes="pos" Text="{Binding PaidCash}" FontSize="22"/>
</Grid>
<Grid ColumnDefinitions="140,*">
<TextBlock Text="Картой" Foreground="#cbd5e1" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Classes="pos" Text="{Binding PaidCard}" FontSize="22"/>
</Grid>
<Border Background="#0b1220" CornerRadius="8" Padding="14">
<!-- Сегменты способа оплаты (слитный переключатель) -->
<Border Background="{DynamicResource PosSubtle}" CornerRadius="12" Padding="4">
<UniformGrid Columns="3">
<Button Classes="pos seg" Classes.on="{Binding IsCash}" Margin="2" Content="Наличные" Command="{Binding SelectCashCommand}"/>
<Button Classes="pos seg" Classes.on="{Binding IsCard}" Margin="2" Content="Карта" Command="{Binding SelectCardCommand}"/>
<Button Classes="pos seg" Classes.on="{Binding IsMixed}" Margin="2" Content="Смешанная" Command="{Binding SelectMixedCommand}"/>
</UniformGrid>
</Border>
<Grid ColumnDefinitions="*,300">
<!-- Левая часть: суммы -->
<StackPanel Grid.Column="0" Spacing="12" Margin="0,0,20,0">
<StackPanel Spacing="4">
<TextBlock Text="Получено наличными" Foreground="{DynamicResource PosTextMuted}" FontSize="14"/>
<TextBox Classes="pos" FontSize="24" Text="{Binding PaidCash}"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding IsMixed}">
<TextBlock Text="Картой" Foreground="{DynamicResource PosTextMuted}" FontSize="14"/>
<TextBox Classes="pos" FontSize="24" Text="{Binding PaidCard}"/>
</StackPanel>
<!-- Быстрые суммы -->
<UniformGrid Columns="3" IsVisible="{Binding !IsCard}">
<Button Classes="pos ghost" Margin="3" FontSize="14.5" Padding="4,0" Content="Без сдачи" Command="{Binding QuickCashCommand}" CommandParameter="exact"/>
<Button Classes="pos ghost" Margin="3" Content="1 000" Command="{Binding QuickCashCommand}" CommandParameter="1000"/>
<Button Classes="pos ghost" Margin="3" Content="2 000" Command="{Binding QuickCashCommand}" CommandParameter="2000"/>
<Button Classes="pos ghost" Margin="3" Content="5 000" Command="{Binding QuickCashCommand}" CommandParameter="5000"/>
<Button Classes="pos ghost" Margin="3" Content="10 000" Command="{Binding QuickCashCommand}" CommandParameter="10000"/>
<Button Classes="pos ghost" Margin="3" Content="20 000" Command="{Binding QuickCashCommand}" CommandParameter="20000"/>
</UniformGrid>
<!-- Сдача -->
<Border Background="{DynamicResource PosBrandSoft}" CornerRadius="12" Padding="16,14">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="СДАЧА" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Change, StringFormat='{}{0:N0} ₸'}" Foreground="#fbbf24" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="СДАЧА" Foreground="{DynamicResource PosText}" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Change, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosBrand}" FontSize="36" FontWeight="Bold"/>
</Grid>
</Border>
<Grid ColumnDefinitions="*,2*">
<Button Classes="pos" Margin="0,0,6,0" HorizontalAlignment="Stretch" Height="64" FontSize="16" Content="Без сдачи" Command="{Binding ExactCashCommand}"/>
<Button Grid.Column="1" Classes="pos primary" HorizontalAlignment="Stretch" Height="64" FontSize="19" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}">
<TextBlock Text="Enter · Провести и печать" TextWrapping="Wrap" TextAlignment="Center"/>
</Button>
</StackPanel>
<!-- Правая часть: цифровая клавиатура 3×4 (ровные квадраты) -->
<UniformGrid Grid.Column="1" Columns="3" Rows="4" VerticalAlignment="Top" IsVisible="{Binding !IsCard}">
<Button Classes="key" Margin="4" Height="72" Content="1" Command="{Binding KeyDigitCommand}" CommandParameter="1"/>
<Button Classes="key" Margin="4" Height="72" Content="2" Command="{Binding KeyDigitCommand}" CommandParameter="2"/>
<Button Classes="key" Margin="4" Height="72" Content="3" Command="{Binding KeyDigitCommand}" CommandParameter="3"/>
<Button Classes="key" Margin="4" Height="72" Content="4" Command="{Binding KeyDigitCommand}" CommandParameter="4"/>
<Button Classes="key" Margin="4" Height="72" Content="5" Command="{Binding KeyDigitCommand}" CommandParameter="5"/>
<Button Classes="key" Margin="4" Height="72" Content="6" Command="{Binding KeyDigitCommand}" CommandParameter="6"/>
<Button Classes="key" Margin="4" Height="72" Content="7" Command="{Binding KeyDigitCommand}" CommandParameter="7"/>
<Button Classes="key" Margin="4" Height="72" Content="8" Command="{Binding KeyDigitCommand}" CommandParameter="8"/>
<Button Classes="key" Margin="4" Height="72" Content="9" Command="{Binding KeyDigitCommand}" CommandParameter="9"/>
<Button Classes="key" Margin="4" Height="72" FontSize="15" Content="Сброс" Command="{Binding KeyClearCommand}"/>
<Button Classes="key" Margin="4" Height="72" Content="0" Command="{Binding KeyDigitCommand}" CommandParameter="0"/>
<Button Classes="key" Margin="4" Height="72" FontSize="20" Content="⌫" Command="{Binding KeyBackCommand}"/>
</UniformGrid>
</Grid>
<Button Classes="pos" HorizontalAlignment="Stretch" Height="44" Content="Esc · Отмена" Command="{Binding CancelCommand}"/>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="66" FontSize="20" Command="{Binding ConfirmCommand}" IsDefault="True" IsEnabled="{Binding Sufficient}">
<TextBlock Text="Провести и печатать · Enter" TextAlignment="Center"/>
</Button>
<Button Classes="pos ghost" HorizontalAlignment="Stretch" Height="44" Content="Esc · Назад" Command="{Binding CancelCommand}"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View file

@ -3,54 +3,57 @@
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.ReturnView"
x:DataType="vm:ReturnViewModel">
<Grid Background="#0f172a" RowDefinitions="Auto,*,Auto" Margin="16">
<StackPanel Grid.Row="0" Spacing="8">
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="#f8fafc"/>
<Grid Background="{DynamicResource PosBg}" RowDefinitions="Auto,*,Auto" Margin="20">
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Text="Возврат по чеку" FontSize="22" FontWeight="Bold" Foreground="{DynamicResource PosText}"/>
<Border Classes="card" Padding="0">
<Grid ColumnDefinitions="*,170">
<TextBox Classes="pos" Watermark="Номер чека или скан QR (Enter)" Text="{Binding ReceiptNumber}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding LookupCommand}"/>
</TextBox.KeyBindings>
<TextBox BorderThickness="0" Background="Transparent" FontSize="18" VerticalContentAlignment="Center" Margin="16,0"
Watermark="Номер чека или скан QR (Enter)" Text="{Binding ReceiptNumber}">
<TextBox.KeyBindings><KeyBinding Gesture="Enter" Command="{Binding LookupCommand}"/></TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}"/>
<Button Grid.Column="1" Classes="pos primary" Margin="6" HorizontalAlignment="Stretch" Content="Найти" Command="{Binding LookupCommand}"/>
</Grid>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa" TextWrapping="Wrap"/>
</Border>
<TextBlock Text="{Binding Message}" Foreground="{DynamicResource PosTextMuted}" TextWrapping="Wrap" FontSize="14"/>
</StackPanel>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Margin="0,10">
<Border Grid.Row="1" Classes="card" Margin="0,12" Padding="8">
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<ListBox.Styles><Style Selector="ListBoxItem"><Setter Property="Padding" Value="0"/><Setter Property="Margin" Value="0,3"/></Style></ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ReturnLineVm">
<Grid ColumnDefinitions="*,190,110,120" MinHeight="58">
<StackPanel VerticalAlignment="Center" Margin="4,6">
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" TextWrapping="Wrap"/>
<TextBlock Text="{Binding AvailText}" Foreground="#94a3b8" FontSize="12.5"/>
<Border Background="{DynamicResource PosSubtle}" CornerRadius="10" Padding="12,8">
<Grid ColumnDefinitions="*,210,110,120">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource PosText}" FontSize="17" FontWeight="Medium" TextWrapping="Wrap"/>
<TextBlock Text="{Binding AvailText}" Foreground="{DynamicResource PosTextMuted}" FontSize="13"/>
</StackPanel>
<!-- Степпер количества к возврату -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="pos" Width="44" Height="40" Padding="0" FontSize="20" Content="−"
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Classes="key" Width="42" Height="42" Padding="0" FontSize="22" Content="−"
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).DecCommand}" CommandParameter="{Binding}"/>
<Border Background="#0b1220" CornerRadius="6" Width="58" Height="40">
<TextBlock Text="{Binding QtyText}" Foreground="#f8fafc" FontSize="17" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Button Classes="pos" Width="44" Height="40" Padding="0" FontSize="20" Content="+"
<TextBlock Text="{Binding QtyText}" Foreground="{DynamicResource PosText}" FontSize="17" FontWeight="SemiBold" MinWidth="54" TextAlignment="Center" VerticalAlignment="Center"/>
<Button Classes="key" Width="42" Height="42" Padding="0" FontSize="20" Content="+"
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).IncCommand}" CommandParameter="{Binding}"/>
</StackPanel>
<Button Grid.Column="2" Classes="pos" Height="40" FontSize="13" Margin="6,0,0,0" Content="Всё"
<Button Grid.Column="2" Classes="pos ghost" Height="40" FontSize="13" Margin="6,0,0,0" Content="Всё"
Command="{Binding $parent[ItemsControl].((vm:ReturnViewModel)DataContext).FullCommand}" CommandParameter="{Binding}"/>
<TextBlock Grid.Column="3" Text="{Binding LineRefund, StringFormat='{}{0:N0} ₸'}" Foreground="#ffffff" FontWeight="SemiBold" FontSize="17" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Grid.Column="3" Text="{Binding LineRefund, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontWeight="SemiBold" FontSize="18" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,8,0"/>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Text="К возврату" Foreground="#cbd5e1" FontSize="18" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16">
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#fca5a5" FontSize="26" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Classes="pos danger" Height="56" Width="230" Content="Оформить возврат" Command="{Binding SubmitCommand}" IsEnabled="{Binding Found}"/>
<Border Grid.Row="2" Classes="card" Padding="16,12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="К возврату" Foreground="{DynamicResource PosTextMuted}" FontSize="14"/>
<TextBlock Text="{Binding RefundTotal, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosDanger}" FontSize="28" FontWeight="Bold"/>
</StackPanel>
<Button Grid.Column="1" Classes="pos danger" Height="56" Width="240" FontSize="17" Content="Оформить возврат" Command="{Binding SubmitCommand}" IsEnabled="{Binding Found}"/>
</Grid>
</Border>
</Grid>
</UserControl>

View file

@ -3,77 +3,135 @@
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.SaleView"
x:DataType="vm:SaleViewModel">
<Grid ColumnDefinitions="*,380" Background="#0f172a">
<!-- Левая часть: чек -->
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto" Margin="16,12,8,12">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,8">
<TextBox Grid.Column="0" Classes="pos" Watermark="Штрихкод / поиск товара (Enter)"
Text="{Binding ScanInput}" FontSize="22">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}"/>
</TextBox.KeyBindings>
<Grid ColumnDefinitions="*,400" Background="{DynamicResource PosBg}">
<!-- ЛЕВО: поиск + избранное + корзина -->
<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">
<Grid ColumnDefinitions="Auto,*">
<PathIcon Grid.Column="0" Width="22" Height="22" Margin="18,0,8,0" Foreground="{DynamicResource PosTextMuted}"
Data="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
<TextBox Grid.Column="1" BorderThickness="0" FontSize="19" Background="Transparent" VerticalContentAlignment="Center"
Watermark="Отсканируйте товар или начните вводить название" Text="{Binding ScanInput}">
<TextBox.KeyBindings><KeyBinding Gesture="Enter" Command="{Binding ScanCommand}"/></TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="1" Classes="pos" Margin="8,0,0,0" Content="Отложить" Command="{Binding ParkCommand}"/>
<Button Grid.Column="2" Classes="pos" Margin="6,0,0,0" Content="Вернуть" Command="{Binding ResumeCommand}"/>
</Grid>
</Border>
<!-- Избранное (быстрые товары) -->
<StackPanel Grid.Row="1" Spacing="8" Margin="0,0,0,14" IsVisible="{Binding HasFavorites}">
<Button Classes="pos ghost" HorizontalAlignment="Left" Height="32" Padding="12,0" FontSize="13.5" Command="{Binding ToggleFavoritesCommand}" BorderThickness="0" Background="Transparent">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Width="12" Height="12" Foreground="{DynamicResource PosTextMuted}" IsVisible="{Binding FavoritesOpen}" Data="M7 10l5 5 5-5z"/>
<PathIcon Width="12" Height="12" Foreground="{DynamicResource PosTextMuted}" IsVisible="{Binding !FavoritesOpen}" Data="M10 17l5-5-5-5z"/>
<TextBlock Text="Избранное" Foreground="{DynamicResource PosTextMuted}" FontWeight="Medium"/>
</StackPanel>
</Button>
<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="2" Classes="card" Padding="0">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,180,120,52" Height="40" Margin="18,6,10,0">
<TextBlock Text="Товар" Foreground="{DynamicResource PosTextMuted}" FontSize="13" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Количество" Foreground="{DynamicResource PosTextMuted}" FontSize="13" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="Сумма" Foreground="{DynamicResource PosTextMuted}" FontSize="13" HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
<Border Grid.Row="1" Background="#111827" CornerRadius="10" Padding="0">
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,110,110,120" Background="#1f2937" Height="38">
<TextBlock Text=" Товар" Foreground="#9ca3af" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="Кол-во" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="2" Text="Цена" Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
<TextBlock Grid.Column="3" Text="Сумма " Foreground="#9ca3af" VerticalAlignment="Center" HorizontalAlignment="Right"/>
</Grid>
<ListBox ItemsSource="{Binding Lines}" Background="Transparent" BorderThickness="0">
<!-- Пустая корзина -->
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="10" IsVisible="{Binding IsCartEmpty}">
<Border Background="{DynamicResource PosSubtle}" CornerRadius="999" Width="72" Height="72">
<PathIcon Width="34" Height="34" Foreground="{DynamicResource PosTextMuted}"
Data="M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zM1 2v2h2l3.6 7.59-1.35 2.45c-.16.28-.25.61-.25.96 0 1.1.9 2 2 2h12v-2H7.42c-.14 0-.25-.11-.25-.25l.03-.12.9-1.63h7.45c.75 0 1.41-.41 1.75-1.03l3.58-6.49c.08-.14.12-.31.12-.48 0-.55-.45-1-1-1H5.21l-.94-2H1zm16 16c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2z"/>
</Border>
<TextBlock Text="Отсканируйте первый товар" Foreground="{DynamicResource PosTextMuted}" FontSize="16"/>
</StackPanel>
<ListBox Grid.Row="1" ItemsSource="{Binding Lines}" SelectedItem="{Binding SelectedLine}"
Background="Transparent" BorderThickness="0" Padding="8,4" IsVisible="{Binding !IsCartEmpty}">
<ListBox.Styles>
<Style Selector="ListBoxItem"><Setter Property="Padding" Value="0"/><Setter Property="Margin" Value="0,3"/><Setter Property="CornerRadius" Value="10"/></Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SaleLineVm">
<Grid ColumnDefinitions="*,110,110,120" MinHeight="46">
<StackPanel VerticalAlignment="Center" Margin="0,4">
<TextBlock Text="{Binding Name}" Foreground="#f3f4f6" FontSize="16" TextWrapping="Wrap"/>
<TextBlock Text="{Binding DiscountText}" Foreground="#fca5a5" FontSize="12.5"
IsVisible="{Binding HasDiscount}"/>
<Border Background="{DynamicResource PosSubtle}" CornerRadius="10" Padding="12,8">
<Grid ColumnDefinitions="*,180,120,52">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource PosText}" FontSize="17" FontWeight="Medium" TextWrapping="Wrap"/>
<TextBlock Text="{Binding DiscountText}" Foreground="{DynamicResource PosBrand}" FontSize="13" IsVisible="{Binding HasDiscount}"/>
<TextBlock Text="{Binding Price, StringFormat='{}{0:N0} ₸ / ед.'}" Foreground="{DynamicResource PosTextMuted}" FontSize="13" IsVisible="{Binding !HasDiscount}"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding QtyText}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Price, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb" FontSize="16" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="#ffffff" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
<!-- Степпер 44×44 -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Classes="key" Width="44" Height="44" FontSize="22" Content="−"
Command="{Binding $parent[ItemsControl].((vm:SaleViewModel)DataContext).DecLineCommand}" CommandParameter="{Binding}"/>
<TextBlock Text="{Binding QtyText}" Foreground="{DynamicResource PosText}" FontSize="16" FontWeight="SemiBold" MinWidth="56" TextAlignment="Center" VerticalAlignment="Center"/>
<Button Classes="key" Width="44" Height="44" FontSize="20" Content="+"
Command="{Binding $parent[ItemsControl].((vm:SaleViewModel)DataContext).IncLineCommand}" CommandParameter="{Binding}"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding LineTotal, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="19" FontWeight="SemiBold" HorizontalAlignment="Right" VerticalAlignment="Center"/>
<Button Grid.Column="3" Classes="key" Width="36" Height="36" Background="Transparent" BorderThickness="0" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="12,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SaleViewModel)DataContext).RemoveLineCommand}" CommandParameter="{Binding}" ToolTip.Tip="Удалить">
<PathIcon Width="18" Height="18" Foreground="{DynamicResource PosDanger}"
Data="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</Button>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Grid>
</Border>
<TextBlock Grid.Row="2" Text="{Binding Message}" Foreground="#60a5fa" Margin="4,8,0,0" FontSize="14"/>
<TextBlock Grid.Row="3" Text="{Binding Message}" Foreground="{DynamicResource PosTextMuted}" Margin="4,10,0,0" FontSize="13.5"/>
</Grid>
<!-- Правая часть: итоги + оплата -->
<Border Grid.Column="1" Background="#1e293b" Margin="8,12,16,12" CornerRadius="10" Padding="18">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="6">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Позиций" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding ItemCount}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Подытог" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding Subtotal, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Скидка" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding Discount, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
<!-- ПРАВО: чек + оплата (компактно, без пустот) -->
<Border Grid.Column="1" Classes="card" Margin="10,18,20,18" Padding="18">
<StackPanel Spacing="12">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Позиций" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding ItemCount}" Foreground="{DynamicResource PosText}" FontSize="15" FontWeight="Medium"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Подытог" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding Subtotal, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="15"/></Grid>
<Grid ColumnDefinitions="*,Auto" IsVisible="{Binding HasAnyDiscount}"><TextBlock Text="Скидка" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding Discount, StringFormat='−{0:N0} ₸'}" Foreground="{DynamicResource PosBrand}" FontSize="15" FontWeight="Medium"/></Grid>
</StackPanel>
<Separator Grid.Row="1" Margin="0,12" Background="#334155"/>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Text="ИТОГО" Foreground="#f8fafc" FontSize="22" FontWeight="Bold" VerticalAlignment="Bottom"/>
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="#34d399" FontSize="34" FontWeight="Bold" HorizontalAlignment="Right"/>
<Border Background="{DynamicResource PosBrandSoft}" CornerRadius="12" Padding="16,14">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="ИТОГО" Foreground="{DynamicResource PosText}" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosBrand}" FontSize="42" FontWeight="Bold"/>
</Grid>
<StackPanel Grid.Row="4" Spacing="8">
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="68" FontSize="22" Command="{Binding PayCommand}">
<TextBlock Text="F12 · ОПЛАТА" TextAlignment="Center"/>
</Border>
<Button Classes="pos primary" HorizontalAlignment="Stretch" Height="72" FontSize="22" Command="{Binding PayCommand}">
<TextBlock Text="Оплатить · F12" TextAlignment="Center"/>
</Button>
<Grid ColumnDefinitions="*,*" >
<Button Classes="pos" Margin="0,0,4,0" FontSize="16" HorizontalAlignment="Stretch">
<TextBlock Text="Скидка" TextAlignment="Center"/>
</Button>
<Button Grid.Column="1" Classes="pos danger" Margin="4,0,0,0" FontSize="16" HorizontalAlignment="Stretch" Command="{Binding ClearCommand}" ToolTip.Tip="Очистить чек (Esc)">
<TextBlock Text="Очистить" TextWrapping="Wrap" TextAlignment="Center"/>
</Button>
</Grid>
<!-- Вторичные действия: ровная сетка 2 колонки -->
<UniformGrid Columns="2">
<Button Classes="pos ghost" Margin="0,0,4,8" Content="Скидка"/>
<Button Classes="pos ghost" Margin="4,0,0,8" Content="Отложить · F7" Command="{Binding ParkCommand}"/>
<Button Classes="pos ghost" Margin="0,0,4,8" Content="Отложенные" Command="{Binding ResumeCommand}"/>
<Button Classes="pos ghost" Margin="4,0,0,8" Content="Возврат · F9" Command="{Binding GoReturnCommand}"/>
</UniformGrid>
<Button Classes="pos ghost" HorizontalAlignment="Stretch" Content="Внесение / Выплата · F10" Command="{Binding GoShiftCommand}"/>
<Button Classes="pos danger" HorizontalAlignment="Stretch" Content="Очистить · Esc" Command="{Binding ClearCommand}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</UserControl>

View file

@ -3,33 +3,56 @@
xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels"
x:Class="foodmarket.Pos.Views.ShiftView"
x:DataType="vm:ShiftViewModel">
<Grid Background="#0f172a">
<Border Background="#1e293b" CornerRadius="12" Width="620" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<Grid Background="{DynamicResource PosBg}">
<Border Classes="card" Width="640" Padding="28" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Z-отчёт смены" FontSize="24" FontWeight="Bold" Foreground="#f8fafc"/>
<TextBlock Grid.Column="1" Text="{Binding Number}" Foreground="#93c5fd" FontSize="18" VerticalAlignment="Center"/>
</Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Размен (на открытие)" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding OpeningFloat, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи наличными" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding SalesCash, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи картой" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding SalesCard, StringFormat='{}{0:N0} ₸'}" Foreground="#e5e7eb"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Возвраты наличными" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding ReturnsCash, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Внесения" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding DrawerIn, StringFormat='+{0:N0} ₸'}" Foreground="#86efac"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Выплаты" Foreground="#94a3b8"/><TextBlock Grid.Column="1" Text="{Binding DrawerOut, StringFormat='-{0:N0} ₸'}" Foreground="#fca5a5"/></Grid>
<Separator Background="#334155"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Должно быть в кассе" Foreground="#cbd5e1" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding ExpectedCash, StringFormat='{}{0:N0} ₸'}" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Факт (пересчёт)" Foreground="#cbd5e1" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding ActualCash, StringFormat='{}{0:N0} ₸'}" Foreground="#f8fafc" FontSize="20" FontWeight="SemiBold"/></Grid>
<Border Background="#0b1220" CornerRadius="8" Padding="12">
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Разница" Foreground="#f8fafc" FontSize="18"/><TextBlock Grid.Column="1" Text="{Binding Difference, StringFormat='{}{0:+#,0;-#,0;0} ₸'}" Foreground="#fbbf24" FontSize="24" FontWeight="Bold"/></Grid>
<TextBlock Text="Смена — Z-отчёт" FontSize="22" FontWeight="Bold" Foreground="{DynamicResource PosText}"/>
<Border Grid.Column="1" Classes="chip" Classes.ok="{Binding IsOpen}" Classes.warn="{Binding !IsOpen}" VerticalAlignment="Center">
<TextBlock Text="{Binding Number}"/>
</Border>
</Grid>
<Border Height="1" Background="{DynamicResource PosBorder}" Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Размен (на открытие)" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding OpeningFloat, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="15"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи наличными" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding SalesCash, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="15"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Продажи картой" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding SalesCard, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="15"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Возвраты наличными" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding ReturnsCash, StringFormat='−{0:N0} ₸'}" Foreground="{DynamicResource PosDanger}" FontSize="15"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Внесения" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding DrawerIn, StringFormat='+{0:N0} ₸'}" Foreground="{DynamicResource PosBrand}" FontSize="15"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Выплаты" Foreground="{DynamicResource PosTextMuted}" FontSize="15"/><TextBlock Grid.Column="1" Text="{Binding DrawerOut, StringFormat='−{0:N0} ₸'}" Foreground="{DynamicResource PosDanger}" FontSize="15"/></Grid>
<Border Height="1" Background="{DynamicResource PosBorder}" Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Должно быть в кассе" Foreground="{DynamicResource PosText}" FontSize="16" FontWeight="SemiBold"/><TextBlock Grid.Column="1" Text="{Binding ExpectedCash, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="18" FontWeight="SemiBold"/></Grid>
<Grid ColumnDefinitions="*,Auto"><TextBlock Text="Факт (пересчёт)" Foreground="{DynamicResource PosText}" FontSize="16"/><TextBlock Grid.Column="1" Text="{Binding ActualCash, StringFormat='{}{0:N0} ₸'}" Foreground="{DynamicResource PosText}" FontSize="18" FontWeight="SemiBold"/></Grid>
<Border CornerRadius="12" Padding="16,12" Background="{DynamicResource PosSubtle}"
Classes.bad="{Binding DiffShortage}">
<Border.Styles>
<Style Selector="Border.bad"><Setter Property="Background" Value="{DynamicResource PosDangerSoft}"/></Style>
</Border.Styles>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Разница" Foreground="{DynamicResource PosText}" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="{Binding DiffLabel}" Foreground="{DynamicResource PosTextMuted}" FontSize="13"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding Difference, StringFormat='{}{0:+#,0;-#,0;0} ₸'}" FontSize="26" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{DynamicResource PosTextMuted}"
Classes.surplus="{Binding DiffSurplus}" Classes.shortage="{Binding DiffShortage}">
<TextBlock.Styles>
<Style Selector="TextBlock.surplus"><Setter Property="Foreground" Value="{DynamicResource PosBrand}"/></Style>
<Style Selector="TextBlock.shortage"><Setter Property="Foreground" Value="{DynamicResource PosDanger}"/></Style>
</TextBlock.Styles>
</TextBlock>
</Grid>
</Border>
<Grid ColumnDefinitions="*,*,*">
<Button Classes="pos" Margin="0,0,4,0" HorizontalAlignment="Stretch" Content="Открыть смену" Command="{Binding OpenShiftCommand}" IsEnabled="{Binding !IsOpen}"/>
<Button Grid.Column="1" Classes="pos" Margin="4,0" HorizontalAlignment="Stretch" Content="Внести" Command="{Binding DepositCashCommand}" IsEnabled="{Binding IsOpen}"/>
<Button Grid.Column="2" Classes="pos" Margin="4,0,0,0" HorizontalAlignment="Stretch" Content="Выплата" Command="{Binding WithdrawCashCommand}" IsEnabled="{Binding IsOpen}"/>
<Button Classes="pos ghost" Margin="0,0,5,0" Content="Открыть смену" Command="{Binding OpenShiftCommand}" IsEnabled="{Binding !IsOpen}"/>
<Button Grid.Column="1" Classes="pos ghost" Margin="5,0" Content="Внести" Command="{Binding DepositCashCommand}" IsEnabled="{Binding IsOpen}"/>
<Button Grid.Column="2" Classes="pos ghost" Margin="5,0,0,0" Content="Выплата" Command="{Binding WithdrawCashCommand}" IsEnabled="{Binding IsOpen}"/>
</Grid>
<Button Classes="pos danger" HorizontalAlignment="Stretch" Height="52" Content="Закрыть смену (Z-отчёт)" Command="{Binding CloseShiftCommand}" IsEnabled="{Binding IsOpen}"/>
<TextBlock Text="{Binding Message}" Foreground="#60a5fa" IsVisible="{Binding Message, Converter={x:Static ObjectConverters.IsNotNull}}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Message}" Foreground="{DynamicResource PosTextMuted}" TextWrapping="Wrap" IsVisible="{Binding Message, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</Border>
</Grid>

View file

@ -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; }
}

View file

@ -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 != "Обычный");
}
}