Compare commits

..

8 Commits

Author SHA1 Message Date
Exest
3fc825e54b Avalonia: Прайс-лист как в WinForms — дрилдаун Название→МГ→Предложения + ввод количества с клавиатуры
- База/дозировка вычисляются на клиенте (порт BuildBaseName/ExtractDosage)
- Три панели: базовые наименования → дозировки (МГ) → предложения поставщиков
- Колонка «Заказ»: ввод числа с клавиатуры (многозначно), Enter — добавить
- Фикс: null-безопасная привязка KeyDown (падало в конструкторе → пустой экран)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 12:02:41 +03:00
Exest
0536d480cb Avalonia: вкладки-документы + нижняя строка состояния + монохромные иконки
- Вкладки-документы: разделы открываются вкладками (несколько сразу),
  закрываются, активная синхронизирована с верхней навигацией
  (ShellViewModel.Tabs/ActiveTab, CloseTabCommand)
- Нижняя строка состояния: Пользователь | Аптека + кнопка «Настройки»
- Монохромные иконки Segoe MDL2 (те же глифы, что в WinForms):
  Прайс E14C, Заказы E7BF, Накладные E8A5, Отчёты EB05, Загрузить E896,
  Отправить E725, Отказы E7BA, Справочник E8F1
- контент показывает активную вкладку

Оболочка приведена к виду исходной программы. Проверено вживную.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 13:00:03 +03:00
Exest
cd2fb57bca Avalonia: черновики получают реальный номер EX-####### (как в WinForms)
- при «Сохранить заказ» резервируется настоящий номер через
  GET /api/buyer/global-sign/next (fallback ЧРН-… если офлайн)
- при отправке черновика этот EX-номер передаётся в заказ (global_sign),
  заказ сохраняет тот же номер
- ApiClient.NextGlobalSignAsync, BuyerOrderCreateRequest.GlobalSign
- DraftStore: номер задаёт вызывающий

Проверено: черновик = EX-0000018, статус «Не отправлен».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:54:43 +03:00
Exest
2420a71ace Avalonia: сборка заказа в Прайс-листе (панель «Мой заказ») + черновики в «Заказы»
- Прайс-лист: 2 колонки — каталог + «Мой заказ» (состав/кол-во/итог/
  грузополучатель/комментарий) + «Сохранить заказ»
- Сохранить -> локальный черновик (DraftStore, %APPDATA%)
- Заказы: черновики вверху «Не отправлен» + «Отправить заказ»/«Удалить»
- как в WinForms: собрал -> сохранил (не отправлен) -> отправил отдельно

Проверено вживую: сборка, сохранение, черновик в Заказах.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:43:47 +03:00
Exest
c677345ba6 Avalonia: общая корзина, добавление из Прайс-листа, Настройки
- CartService: единая корзина для Прайс-листа и Отправить
- Прайс-лист: выбор позиции + кол-во + «Добавить в заказ», индикатор корзины
- Настройки (диалог ⚙ в шапке): адрес API, тема (свет/тьма, применяется
  сразу), грузополучатель по умолчанию; сохранение в %APPDATA%
- SettingsStore (Core) + AppConfig читает сохранённый API-адрес
- тема применяется на старте

Проверено вживую: добавление из Прайс-листа в корзину, диалог настроек.
Реальная отправка заказа (POST) — собрана и связана, живой заказ по явному «да»
(автозащита блокирует боевую транзакцию без подтверждения).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:26:53 +03:00
Exest
43468ac693 Avalonia: раздел «Загрузить» (скачивание прайса) + уплотнение верхней панели
- Добавлена вкладка «Загрузить» (между Отчёты и Отправить, как в исходной
  программе): постраничная загрузка прайса с прогрессом, кэш в памяти
  (PriceCache), итог «N позиций от K поставщиков» + timestamp
- Верхняя навигация уплотнена под 8 вкладок (Справочник больше не обрезается)
- Core: PriceCache

Проверено вживую: все 8 вкладок влезают, загрузка прайса работает (500 поз.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:05:52 +03:00
Exest
b13a867f8d Avalonia: раздел «Отправить» (корзина + создание заказа)
- Отправить: поиск по прайсу → корзина (кол-во через NumericUpDown,
  сумма/итого) → выбор грузополучателя + комментарий → создание заказа
  POST /api/buyer/orders
- Core: CreateOrderAsync + DTO (BuyerOrderCreateRequest/ItemReq/Response)
- Отказы: заглушка с точным пояснением (нужен серверный эндпоинт;
  RefuseItemsCount живёт только в локальной SQLite WinForms)

Итог: 6/7 разделов функциональны (Прайс-лист, Заказы, Накладные, Отчёты,
Справочник, Отправить), Отказы — заглушка. Собирается.
⚠️ Реальная отправка заказа (POST) не тестировалась — создаёт заказ на
проде; проверять вживую отдельно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:30:57 +03:00
Exest
77dfb08ef4 Avalonia: Прайс-лист, Накладные, Справочник (5/7 разделов рабочие)
- Прайс-лист: поиск + грид на рабочем /api/supplier-prices/summary
  (наименование, форма, производитель, поставщик, цена)
- Накладные: реестр приходных документов (из buyer/report)
- Справочник: профиль покупателя + грузополучатели (buyer/locations)
- Core: GetPriceSummaryAsync, GetBuyerLocationsAsync + DTO
- README: статус разделов + примечание про buyer/orders 500 (фикс застейджен)

Рабочие: Прайс-лист, Заказы, Накладные, Отчёты, Справочник.
Осталось: Отправить (корзина+DBF), Отказы (нужен эндпоинт), автообновление.
Собирается; рантайм новых 3 разделов не проверен (клиент за игрой — окна не поднимал).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:20:36 +03:00
33 changed files with 1725 additions and 58 deletions

View File

@ -18,6 +18,10 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
RequestedThemeVariant = Elfisa.Core.SettingsStore.Current.Theme == "Dark"
? global::Avalonia.Styling.ThemeVariant.Dark
: global::Avalonia.Styling.ThemeVariant.Light;
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow

View File

@ -52,14 +52,22 @@ dotnet publish src/Elfisa.Avalonia -c Release -r win-x64 --self-contained #
| Раздел | Что нужно | Сложность |
|---|---|---|
| Прайс-лист | поиск/каталог, наценки, добавление в заказ, большой грид | высокая |
| Заказы | список + карточка заказа + позиции | средняя |
| Накладные | список приходов | средняя |
| Отправить | сборка заявки + выгрузка DBF (портировать `orderexport`) | средняя |
| Отказы | список отказных позиций | низкая |
| Справочник | контрагенты, грузополучатели, настройки | средняя |
| Автообновление | аналог AutoUpdater под каждую ОС | средняя |
| ✅ Прайс-лист | поиск + грид (/api/supplier-prices/summary) | готово (без добавления в заказ) |
| ✅ Заказы | список + карточка заказа с позициями | готово |
| ✅ Накладные | реестр приходных документов | готово |
| ✅ Отчёты | 4 типа + ИИ → Word/Excel | готово |
| ✅ Справочник | профиль + грузополучатели | готово |
| ✅ Отправить | поиск → корзина → создание заказа (POST /api/buyer/orders) | готов UI; реальная отправка не тестировалась (создаёт заказ на проде) |
| ⏳ Отказы | список отказных позиций | нужен серверный эндпоинт (RefuseItemsCount есть только в локальной БД WinForms) |
| ⏳ Автообновление | аналог AutoUpdater под каждую ОС | не начато |
**Порядок рекомендую:** Заказы → Прайс-лист → Отправить → остальное.
Бизнес-логика каждого — обычный C#, переносится как есть; переписывается
только UI (WinForms → Avalonia XAML), что и есть основная работа.
**Прайс-лист/Заказы/Накладные** строятся на рабочих эндпоинтах
(`supplier-prices/summary`, `buyer/report`). «Родные» `buyer/orders` и
`buyer/catalog` возвращают 500 (баг квотинга PascalCase в GORM-билдере).
Фикс для **buyer/orders** готов и застейджен (`es_api_service.exe.new`,
apply-new-exe.bat) — после деплоя Заказы/Накладные перейдут на него.
Каталог не используется (берём рабочий summary), его фикс — по желанию.
Осталось: **Отправить** (корзина + создание заказа + DBF) и **Отказы**,
плюс автообновление и сохранение сессии. Логика переносится из WinForms,
переписывается только UI (XAML).

View File

@ -123,8 +123,8 @@
<Setter Property="Padding" Value="0"/>
</Style>
<Style Selector="ListBox.topnav ListBoxItem">
<Setter Property="Padding" Value="10,7"/>
<Setter Property="Margin" Value="3,0"/>
<Setter Property="Padding" Value="6,7"/>
<Setter Property="Margin" Value="2,0"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
@ -135,6 +135,30 @@
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
</Style>
<!-- ============ Вкладки-документы ============ -->
<Style Selector="ListBox.tabs">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem">
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Margin" Value="3,6"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource RowAlt}"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentSoft}"/>
</Style>
<Style Selector="ListBox.tabs ListBoxItem:selected">
<Setter Property="Foreground" Value="{DynamicResource AccentPressed}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<!-- ============ Таблица ============ -->
<Style Selector="DataGrid">
<Setter Property="Background" Value="{DynamicResource Surface}"/>

View File

@ -0,0 +1,71 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
public partial class CartItem : ObservableObject
{
public PriceItem Source { get; }
public string Name => Source.DrugName ?? "";
public string Supplier => Source.SupplierName ?? "";
public decimal Price => Source.Price;
[ObservableProperty] private decimal _qty = 1;
public decimal LineSum => Price * Qty;
public event Action? Changed;
public CartItem(PriceItem src) => Source = src;
partial void OnQtyChanged(decimal value)
{
OnPropertyChanged(nameof(LineSum));
Changed?.Invoke();
}
}
/// <summary>Единая корзина заказа — общая для «Прайс-листа» и «Отправить».</summary>
public sealed class CartService
{
public static CartService Instance { get; } = new();
public ObservableCollection<CartItem> Items { get; } = new();
public event Action? Changed;
public decimal Total => Items.Sum(c => c.LineSum);
public int Count => Items.Count;
public void Add(PriceItem src, decimal qty = 1)
{
if (src is null || string.IsNullOrEmpty(src.SupplierPriceId)) return;
var existing = Items.FirstOrDefault(c => c.Source.SupplierPriceId == src.SupplierPriceId);
if (existing != null) existing.Qty += qty;
else
{
var ci = new CartItem(src) { Qty = qty };
ci.Changed += Raise;
Items.Add(ci);
}
Raise();
}
public void Remove(CartItem item)
{
if (item is null) return;
item.Changed -= Raise;
Items.Remove(item);
Raise();
}
public void Clear()
{
foreach (var i in Items) i.Changed -= Raise;
Items.Clear();
Raise();
}
private void Raise() => Changed?.Invoke();
}

View File

@ -0,0 +1,48 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Справочник покупателя: профиль + грузополучатели (адреса).</summary>
public partial class DirectoryViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public string UserName => Session.Current.Username ?? "";
public string RoleLabel => Session.Current.Role switch
{
"buyer" => "Покупатель",
"supplier" => "Поставщик",
"manager" => "Менеджер",
"admin" => "Администратор",
_ => Session.Current.Role ?? ""
};
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
public DirectoryViewModel()
{
_api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated) _ = LoadAsync();
}
private async Task LoadAsync()
{
try
{
Busy = true;
var locs = await _api.GetBuyerLocationsAsync();
Locations.Clear();
foreach (var l in locs) Locations.Add(l);
if (Locations.Count == 0) Status = "Грузополучатели не заданы.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
}

View File

@ -0,0 +1,72 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
public sealed class InvoiceRow
{
public string Date { get; init; } = "";
public string Number { get; init; } = "";
public string Supplier { get; init; } = "";
public string Location { get; init; } = "";
public int ItemsCount { get; init; }
public decimal Sum { get; init; }
}
/// <summary>
/// Накладные — реестр приходных документов. Строится из /api/buyer/report
/// (после деплоя фикса /api/buyer/orders можно перейти на «родной» источник).
/// </summary>
public partial class InvoicesViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
public ObservableCollection<InvoiceRow> Rows { get; } = new();
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
[ObservableProperty] private string _totals = "";
public InvoicesViewModel()
{
_api.SetToken(Session.Current.Token);
if (Session.Current.IsAuthenticated) _ = LoadAsync();
}
private async Task LoadAsync()
{
try
{
Busy = true;
var to = DateTime.Today;
var from = to.AddMonths(-24);
var lines = await _api.GetBuyerReportAsync(from, to);
Rows.Clear();
foreach (var g in lines.GroupBy(l => l.OrderId)
.OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue))
{
var f = g.First();
Rows.Add(new InvoiceRow
{
Date = f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "",
Number = f.GlobalSign ?? "",
Supplier = string.Join(", ", g.Select(x => x.Supplier)
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Location = f.Location ?? "",
ItemsCount = g.Count(),
Sum = g.Sum(x => x.Sum)
});
}
Totals = $"Накладных: {Rows.Count} Сумма: {Rows.Sum(r => r.Sum):N2}";
if (Rows.Count == 0) Status = "Накладных за последние 2 года нет.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
}

View File

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>«Загрузить» — скачивание актуального прайса с сервера в память.</summary>
public partial class LoadPriceViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
[ObservableProperty] private bool _busy;
[ObservableProperty] private double _progress; // 0..100
[ObservableProperty] private string _progressText = "";
[ObservableProperty] private string? _result;
public string LastLoaded =>
PriceCache.LoadedAt is { } dt
? $"Последняя загрузка: {dt:dd.MM.yyyy HH:mm} — {PriceCache.Items.Count} позиций"
: "Прайс ещё не загружался в этой сессии.";
public LoadPriceViewModel()
{
_api.SetToken(Session.Current.Token);
}
[RelayCommand]
private async Task LoadAsync()
{
Result = null;
try
{
Busy = true;
Progress = 0;
ProgressText = "Соединение с сервером…";
var all = new List<PriceItem>();
const int page = 500;
int offset = 0, total = 0;
while (true)
{
var resp = await _api.GetPriceSummaryAsync(null, limit: page, offset: offset);
var batch = resp.Summary ?? new();
if (resp.TotalDrugs > 0) total = resp.TotalDrugs;
all.AddRange(batch);
Progress = total > 0 ? Math.Min(100.0, all.Count * 100.0 / total) : 0;
ProgressText = total > 0
? $"Загружено {all.Count} из {total} позиций…"
: $"Загружено {all.Count} позиций…";
offset += batch.Count;
if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 30000)
break;
}
PriceCache.Items = all;
PriceCache.LoadedAt = DateTime.Now;
Progress = 100;
ProgressText = "Готово.";
var suppliers = all.Select(x => x.SupplierName).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().Count();
Result = $"Прайс загружен: {all.Count} позиций от {suppliers} поставщик(ов).";
OnPropertyChanged(nameof(LastLoaded));
}
catch (Exception ex)
{
Result = "Ошибка загрузки: " + ex.Message;
ProgressText = "";
}
finally { Busy = false; }
}
}

View File

@ -15,6 +15,8 @@ public sealed class OrderItemRow
public decimal Qty { get; init; }
public decimal Price { get; init; }
public decimal Sum { get; init; }
public string? SupplierPriceId { get; init; }
public string? Barcode { get; init; }
}
public sealed class OrderSummary
@ -27,12 +29,16 @@ public sealed class OrderSummary
public int ItemsCount { get; init; }
public decimal Sum { get; init; }
public ObservableCollection<OrderItemRow> Items { get; init; } = new();
public bool IsDraft { get; init; }
public string? DraftId { get; init; }
public string? LocationId { get; init; }
public string? Comment { get; init; }
}
/// <summary>
/// Заказы: список + состав. Пока строится из /api/buyer/report (он работает);
/// после деплоя фикса /api/buyer/orders можно перейти на «родной» эндпоинт
/// (даст ещё PlacedAt, Comment, отмену/размещение).
/// Заказы: локальные черновики (статус «Не отправлен») + серверные заказы (из /api/buyer/report).
/// Черновик можно отправить (создать реальный заказ) или удалить.
/// </summary>
public partial class OrdersViewModel : ViewModelBase
{
@ -57,11 +63,38 @@ public partial class OrdersViewModel : ViewModelBase
try
{
Busy = true;
Orders.Clear();
// 1) Локальные черновики — вверху, статус «Не отправлен»
foreach (var d in DraftStore.All)
{
var items = new ObservableCollection<OrderItemRow>(
d.Items.Select(i => new OrderItemRow
{
Name = i.Name, Qty = i.Qty, Price = i.Price, Sum = i.LineSum,
SupplierPriceId = i.SupplierPriceId, Barcode = i.Barcode
}));
Orders.Add(new OrderSummary
{
Date = d.CreatedAt.ToString("dd.MM.yyyy"),
Number = d.Number,
Supplier = string.Join(", ", d.Items.Select(i => i.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Location = d.LocationAddress ?? "",
Status = "Не отправлен",
ItemsCount = d.ItemsCount,
Sum = d.Total,
Items = items,
IsDraft = true,
DraftId = d.Id,
LocationId = d.LocationId,
Comment = d.Comment
});
}
// 2) Серверные заказы
var to = DateTime.Today;
var from = to.AddMonths(-24);
var lines = await _api.GetBuyerReportAsync(from, to);
Orders.Clear();
foreach (var g in lines.GroupBy(l => l.OrderId)
.OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue))
{
@ -69,19 +102,14 @@ public partial class OrdersViewModel : ViewModelBase
var items = new ObservableCollection<OrderItemRow>(
g.Select(l => new OrderItemRow
{
Name = l.ItemName ?? "",
Code = l.ItemCode ?? "",
Qty = l.Qty,
Price = l.UnitPrice,
Sum = l.Sum
Name = l.ItemName ?? "", Code = l.ItemCode ?? "",
Qty = l.Qty, Price = l.UnitPrice, Sum = l.Sum
}));
Orders.Add(new OrderSummary
{
Date = f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "",
Number = f.GlobalSign ?? "",
Supplier = string.Join(", ", g.Select(x => x.Supplier)
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Supplier = string.Join(", ", g.Select(x => x.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Location = f.Location ?? "",
Status = f.Status ?? "",
ItemsCount = g.Count(),
@ -91,9 +119,47 @@ public partial class OrdersViewModel : ViewModelBase
}
SelectedOrder = Orders.FirstOrDefault();
if (Orders.Count == 0) Status = "Заказов за последние 2 года нет.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task SendDraftAsync()
{
if (SelectedOrder is not { IsDraft: true } d) return;
Status = null;
try
{
Busy = true;
var req = new BuyerOrderCreateRequest
{
GlobalSign = d.Number.StartsWith("EX-", StringComparison.OrdinalIgnoreCase) ? d.Number : null,
LocationId = d.LocationId,
Comment = d.Comment,
Items = d.Items.Select(i => new BuyerOrderItemReq
{
SupplierPriceId = i.SupplierPriceId ?? "",
Qty = (double)i.Qty,
ItemName = i.Name,
Barcode = i.Barcode
}).ToList()
};
var resp = await _api.CreateOrderAsync(req);
if (d.DraftId is { } id) DraftStore.Remove(id);
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} отправлен ({resp.ItemsCount} поз., {resp.TotalAmount:N2}).";
await LoadAsync();
}
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task DeleteDraftAsync()
{
if (SelectedOrder is not { IsDraft: true } d || d.DraftId is not { } id) return;
DraftStore.Remove(id);
await LoadAsync();
Status = "Черновик удалён.";
}
}

View File

@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Строка предложения с редактируемым количеством «Заказ» (как колонка «Заказ» в WinForms).</summary>
public partial class OfferRow : ObservableObject
{
public PriceItem Source { get; }
public string? DrugName => Source.DrugName;
public string? SupplierName => Source.SupplierName;
public decimal Price => Source.Price;
public decimal Quantity => Source.Quantity;
[ObservableProperty] private decimal _orderQty = 1;
public OfferRow(PriceItem src) => Source = src;
}
/// <summary>
/// Прайс-лист + сборка заказа. Каталог — как в старой программе:
/// база (название) → дозировка (МГ) → предложения поставщиков. Справа «Мой заказ».
/// «Сохранить заказ» кладёт черновик в «Заказы» (статус «Не отправлен»).
/// </summary>
public partial class PriceListViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
private readonly CartService _cart = CartService.Instance;
// Плоский список предложений + предвычисленные база/МГ (как «Базовое наименование»/«МГ» в WinForms).
private readonly List<(PriceItem Item, string BaseName, string Mg)> _indexed = new();
private const string EmptyMgDisplay = "(без МГ)";
private static readonly Regex DosageRegex = new(
@"(\d+(?:[.,]\d+)?\s*(?:мг|мкг|г|мл|МЕ|ме)\b)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Три панели дрилдауна
public ObservableCollection<string> BaseNames { get; } = new();
public ObservableCollection<string> MgOptions { get; } = new();
public ObservableCollection<OfferRow> Offers { get; } = new();
public ObservableCollection<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private string _search = "";
[ObservableProperty] private string? _selectedBaseName;
[ObservableProperty] private string? _selectedMg;
[ObservableProperty] private OfferRow? _selectedOffer;
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
[ObservableProperty] private string _comment = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
[ObservableProperty] private int _total;
[ObservableProperty] private decimal _cartTotal;
public PriceListViewModel()
{
_api.SetToken(Session.Current.Token);
_cart.Changed += Recalc;
Recalc();
if (Session.Current.IsAuthenticated)
{
_ = SearchAsync();
_ = LoadLocationsAsync();
}
}
private void Recalc() => CartTotal = _cart.Total;
private async Task LoadLocationsAsync()
{
try
{
var locs = await _api.GetBuyerLocationsAsync();
Locations.Clear();
foreach (var l in locs) Locations.Add(l);
var savedId = SettingsStore.Current.DefaultLocationId;
SelectedLocation = Locations.FirstOrDefault(l => l.BuyerLocationId == savedId)
?? Locations.FirstOrDefault(l => l.IsDefault)
?? Locations.FirstOrDefault();
}
catch { }
}
[RelayCommand]
private async Task SearchAsync()
{
Status = null;
try
{
Busy = true;
var resp = await _api.GetPriceSummaryAsync(Search, limit: 300);
RebuildIndex(resp.Summary ?? new());
Total = resp.TotalDrugs;
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
/// <summary>Пересобирает индекс база/МГ и список базовых наименований.</summary>
private void RebuildIndex(List<PriceItem> items)
{
_indexed.Clear();
foreach (var it in items)
{
var mg = ExtractDosage(it.DrugName);
var baseName = BuildBaseName(it.DrugName, it.TradeName, mg);
if (string.IsNullOrWhiteSpace(baseName)) continue;
_indexed.Add((it, baseName, mg));
}
// Сброс выбора и панелей
SelectedBaseName = null;
MgOptions.Clear();
Offers.Clear();
SelectedOffer = null;
var names = _indexed
.Select(x => x.BaseName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
BaseNames.Clear();
foreach (var n in names) BaseNames.Add(n);
}
// База выбрана → заполняем список дозировок.
partial void OnSelectedBaseNameChanged(string? value)
{
MgOptions.Clear();
Offers.Clear();
SelectedOffer = null;
SelectedMg = null;
if (string.IsNullOrEmpty(value)) return;
var mgs = _indexed
.Where(x => string.Equals(x.BaseName, value, StringComparison.OrdinalIgnoreCase))
.Select(x => string.IsNullOrEmpty(x.Mg) ? EmptyMgDisplay : x.Mg)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(m => m, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var m in mgs) MgOptions.Add(m);
// Если дозировка одна — сразу её выбираем (меньше кликов).
if (MgOptions.Count == 1) SelectedMg = MgOptions[0];
}
// Дозировка выбрана → показываем предложения (фильтр база + МГ).
partial void OnSelectedMgChanged(string? value)
{
Offers.Clear();
SelectedOffer = null;
if (string.IsNullOrEmpty(SelectedBaseName) || string.IsNullOrEmpty(value)) return;
var wantMg = string.Equals(value, EmptyMgDisplay, StringComparison.Ordinal) ? "" : value;
var offers = _indexed
.Where(x => string.Equals(x.BaseName, SelectedBaseName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Mg, wantMg, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.Item.Price)
.Select(x => new OfferRow(x.Item))
.ToList();
foreach (var o in offers) Offers.Add(o);
SelectedOffer = Offers.FirstOrDefault();
}
private static string ExtractDosage(string? drugName)
{
if (string.IsNullOrWhiteSpace(drugName)) return string.Empty;
var m = DosageRegex.Match(drugName);
return m.Success ? m.Value.Trim() : string.Empty;
}
private static string BuildBaseName(string? drugName, string? tradeName, string dosage)
{
if (!string.IsNullOrWhiteSpace(tradeName)) return tradeName.Trim();
var name = drugName ?? string.Empty;
if (!string.IsNullOrWhiteSpace(dosage))
{
var idx = name.IndexOf(dosage, StringComparison.OrdinalIgnoreCase);
if (idx >= 0) name = name.Remove(idx, dosage.Length);
}
name = Regex.Replace(name, @"\s{2,}", " ").Trim(' ', ',', '-', '.', ';');
return string.IsNullOrWhiteSpace(name) ? (drugName ?? string.Empty).Trim() : name;
}
[RelayCommand]
private void AddToOrder() => AddSelectedOffer();
/// <summary>Добавляет выбранное предложение в заказ с его количеством (Enter в таблице / кнопка).</summary>
public void AddSelectedOffer()
{
if (SelectedOffer is null) { Status = "Выберите предложение в списке."; return; }
var q = SelectedOffer.OrderQty <= 0 ? 1 : SelectedOffer.OrderQty;
_cart.Add(SelectedOffer.Source, q);
SelectedOffer.OrderQty = 1;
Status = null;
}
[RelayCommand]
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
[RelayCommand]
private async Task SaveDraftAsync()
{
if (_cart.Count == 0) { Status = "Заказ пуст — добавьте позиции."; return; }
try
{
Busy = true;
// Резервируем настоящий номер заказа EX-####### (как в WinForms)
var sign = await _api.NextGlobalSignAsync();
var draft = new DraftOrder
{
Number = string.IsNullOrWhiteSpace(sign) ? $"ЧРН-{DateTime.Now:HHmmss}" : sign!,
LocationId = SelectedLocation?.BuyerLocationId,
LocationAddress = SelectedLocation?.Address,
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
Items = _cart.Items.Select(c => new DraftItem
{
SupplierPriceId = c.Source.SupplierPriceId ?? "",
Name = c.Name,
Supplier = c.Supplier,
Barcode = c.Source.Barcode,
Price = c.Price,
Qty = c.Qty
}).ToList()
};
DraftStore.Add(draft);
_cart.Clear();
Comment = "";
Status = $"Заказ сохранён: {draft.Number} → раздел «Заказы» (не отправлен).";
}
catch (Exception ex) { Status = "Ошибка сохранения: " + ex.Message; }
finally { Busy = false; }
}
}

View File

@ -0,0 +1,106 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>
/// Отправить: поиск по прайсу → корзина (общая с Прайс-листом) → создание заказа.
/// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
/// </summary>
public partial class SendOrderViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
private readonly CartService _cart = CartService.Instance;
public ObservableCollection<PriceItem> Results { get; } = new();
public ObservableCollection<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private string _search = "";
[ObservableProperty] private PriceItem? _selectedResult;
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
[ObservableProperty] private string _comment = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
[ObservableProperty] private decimal _cartTotal;
public SendOrderViewModel()
{
_api.SetToken(Session.Current.Token);
_cart.Changed += Recalc;
Recalc();
if (Session.Current.IsAuthenticated) _ = InitAsync();
}
private async Task InitAsync()
{
try
{
var locs = await _api.GetBuyerLocationsAsync();
Locations.Clear();
foreach (var l in locs) Locations.Add(l);
SelectedLocation = Locations.FirstOrDefault(l => l.IsDefault) ?? Locations.FirstOrDefault();
await SearchAsync();
}
catch (Exception ex) { Status = ex.Message; }
}
[RelayCommand]
private async Task SearchAsync()
{
try
{
Busy = true; Status = null;
var resp = await _api.GetPriceSummaryAsync(Search, limit: 200);
Results.Clear();
foreach (var it in resp.Summary ?? new()) Results.Add(it);
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void AddToCart()
{
if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; }
_cart.Add(SelectedResult);
}
[RelayCommand]
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
private void Recalc() => CartTotal = _cart.Total;
[RelayCommand]
private async Task SendAsync()
{
Status = null;
if (_cart.Count == 0) { Status = "Корзина пуста."; return; }
try
{
Busy = true;
var req = new BuyerOrderCreateRequest
{
LocationId = SelectedLocation?.BuyerLocationId,
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
Items = _cart.Items.Select(c => new BuyerOrderItemReq
{
SupplierPriceId = c.Source.SupplierPriceId ?? "",
Qty = (double)c.Qty,
ItemName = c.Source.DrugName,
Barcode = c.Source.Barcode
}).ToList()
};
var resp = await _api.CreateOrderAsync(req);
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} создан: {resp.ItemsCount} поз., {resp.TotalAmount:N2}.";
_cart.Clear();
}
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
finally { Busy = false; }
}
}

View File

@ -0,0 +1,43 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
public partial class SettingsViewModel : ViewModelBase
{
private readonly ApiClient _api = new();
[ObservableProperty] private string _apiBaseUrl = "";
public string[] Themes { get; } = { "Светлая", "Тёмная" };
[ObservableProperty] private int _themeIndex;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
public SettingsViewModel()
{
_api.SetToken(Session.Current.Token);
ApiBaseUrl = SettingsStore.Current.ApiBaseUrl ?? AppConfig.DefaultApiBaseUrl;
ThemeIndex = SettingsStore.Current.Theme == "Dark" ? 1 : 0;
if (Session.Current.IsAuthenticated) _ = LoadLocationsAsync();
}
private async Task LoadLocationsAsync()
{
try
{
var locs = await _api.GetBuyerLocationsAsync();
Locations.Clear();
foreach (var l in locs) Locations.Add(l);
var savedId = SettingsStore.Current.DefaultLocationId;
SelectedLocation = Locations.FirstOrDefault(l => l.BuyerLocationId == savedId)
?? Locations.FirstOrDefault(l => l.IsDefault)
?? Locations.FirstOrDefault();
}
catch { /* локации не критичны для настроек */ }
}
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Elfisa.Core;
@ -10,7 +11,7 @@ namespace Elfisa.Avalonia.ViewModels;
public class NavItem
{
public string Title { get; }
public string Icon { get; }
public string Icon { get; } // глиф Segoe MDL2 Assets
public Func<ViewModelBase> Factory { get; }
public NavItem(string title, string icon, Func<ViewModelBase> factory)
{
@ -18,14 +19,29 @@ public class NavItem
}
}
/// <summary>Открытая вкладка-документ.</summary>
public sealed class DocumentTab
{
public string Title { get; }
public string Key { get; }
public ViewModelBase Content { get; }
public DocumentTab(string title, string key, ViewModelBase content)
{
Title = title; Key = key; Content = content;
}
}
public partial class ShellViewModel : ViewModelBase
{
private readonly Action _logout;
private readonly ApiClient _api = new();
private bool _sync;
public ObservableCollection<NavItem> NavItems { get; }
public ObservableCollection<DocumentTab> Tabs { get; } = new();
[ObservableProperty] private NavItem? _selectedNav;
[ObservableProperty] private ViewModelBase? _currentPage;
[ObservableProperty] private DocumentTab? _activeTab;
public string UserName => Session.Current.Username ?? "";
public string RoleLabel => Session.Current.Role switch
@ -37,23 +53,84 @@ public partial class ShellViewModel : ViewModelBase
_ => Session.Current.Role ?? ""
};
[ObservableProperty] private string _pharmacyAddress = "";
// Segoe MDL2 Assets — те же глифы, что в WinForms-версии
private const string IcoPrice = "";
private const string IcoOrders = "";
private const string IcoInvoices = "";
private const string IcoReports = "";
private const string IcoUpload = "";
private const string IcoSend = "";
private const string IcoRefusals = "";
private const string IcoDirectory = "";
public ShellViewModel(Action logout)
{
_logout = logout;
_api.SetToken(Session.Current.Token);
NavItems = new ObservableCollection<NavItem>
{
new("Прайс-лист", "🧾", () => new PlaceholderPageViewModel("Прайс-лист", "Каталог, поиск и добавление позиций в заказ.")),
new("Заказы", "🛒", () => new OrdersViewModel()),
new("Накладные", "📄", () => new PlaceholderPageViewModel("Накладные", "Приход товара по заказам.")),
new("Отчёты", "📊", () => new ReportsViewModel()),
new("Отправить", "📤", () => new PlaceholderPageViewModel("Отправить", "Формирование и выгрузка заявок поставщику (DBF).")),
new("Отказы", "⛔", () => new PlaceholderPageViewModel("Отказы", "Отказные позиции по заказам.")),
new("Справочник", "📖", () => new PlaceholderPageViewModel("Справочник", "Контрагенты, грузополучатели, настройки.")),
new("Прайс-лист", IcoPrice, () => new PriceListViewModel()),
new("Заказы", IcoOrders, () => new OrdersViewModel()),
new("Накладные", IcoInvoices, () => new InvoicesViewModel()),
new("Отчёты", IcoReports, () => new ReportsViewModel()),
new("Загрузить", IcoUpload, () => new LoadPriceViewModel()),
new("Отправить", IcoSend, () => new SendOrderViewModel()),
new("Отказы", IcoRefusals, () => new PlaceholderPageViewModel("Отказы",
"Отказные позиции по заказам. Нужен серверный эндпоинт (RefuseItemsCount сейчас только в локальной БД WinForms).")),
new("Справочник", IcoDirectory, () => new DirectoryViewModel()),
};
SelectedNav = NavItems.First(n => n.Title == "Отчёты");
if (Session.Current.IsAuthenticated) _ = LoadPharmacyAsync();
}
partial void OnSelectedNavChanged(NavItem? value) => CurrentPage = value?.Factory();
private async Task LoadPharmacyAsync()
{
try
{
var locs = await _api.GetBuyerLocationsAsync();
var def = locs.FirstOrDefault(l => l.IsDefault) ?? locs.FirstOrDefault();
PharmacyAddress = def?.Address ?? "";
}
catch { }
}
partial void OnSelectedNavChanged(NavItem? value)
{
if (_sync || value is null) return;
OpenOrActivate(value);
}
private void OpenOrActivate(NavItem nav)
{
var existing = Tabs.FirstOrDefault(t => t.Key == nav.Title);
if (existing is not null) { ActiveTab = existing; return; }
var tab = new DocumentTab(nav.Title, nav.Title, nav.Factory());
Tabs.Add(tab);
ActiveTab = tab;
}
partial void OnActiveTabChanged(DocumentTab? value)
{
if (value is null) return;
_sync = true;
SelectedNav = NavItems.FirstOrDefault(n => n.Title == value.Key);
_sync = false;
}
[RelayCommand]
private void CloseTab(DocumentTab? tab)
{
if (tab is null) return;
int idx = Tabs.IndexOf(tab);
bool wasActive = ReferenceEquals(ActiveTab, tab);
Tabs.Remove(tab);
if (wasActive)
ActiveTab = Tabs.Count == 0 ? null : Tabs[Math.Min(idx, Tabs.Count - 1)];
}
[RelayCommand] private void Logout() => _logout();
}

View File

@ -0,0 +1,56 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:core="using:Elfisa.Core"
x:Class="Elfisa.Avalonia.Views.DirectoryView"
x:DataType="vm:DirectoryViewModel">
<ScrollViewer>
<StackPanel Margin="24" Spacing="14">
<StackPanel Spacing="2">
<TextBlock Text="Справочник" Classes="h1"/>
<TextBlock Text="Профиль и грузополучатели" Classes="muted"/>
</StackPanel>
<!-- Профиль -->
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
<StackPanel Spacing="10">
<TextBlock Text="Учётная запись" Classes="h2"/>
<Grid ColumnDefinitions="160,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Логин" Classes="muted"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding UserName}" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Роль" Classes="muted" Margin="0,6,0,0"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding RoleLabel}" Foreground="{DynamicResource Text}" Margin="0,6,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- Грузополучатели -->
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
<StackPanel Spacing="10">
<TextBlock Text="Грузополучатели (адреса)" Classes="h2"/>
<ItemsControl ItemsSource="{Binding Locations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="core:BuyerLocationDto">
<Border Padding="12" Margin="0,0,0,8" CornerRadius="9"
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<StackPanel Spacing="3">
<TextBlock Text="{Binding Address}" Foreground="{DynamicResource Text}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{Binding RegionName}" Classes="muted"/>
<Border Background="{DynamicResource AccentSoft}" CornerRadius="5" Padding="6,1"
IsVisible="{Binding IsDefault}">
<TextBlock Text="по умолчанию" FontSize="11" Foreground="{DynamicResource AccentPressed}"/>
</Border>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding Status}" Classes="muted"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@ -0,0 +1,9 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Elfisa.Avalonia.Views;
public partial class DirectoryView : UserControl
{
public DirectoryView() => AvaloniaXamlLoader.Load(this);
}

View File

@ -0,0 +1,39 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.InvoicesView"
x:DataType="vm:InvoicesViewModel">
<DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Накладные" Classes="h1"/>
<TextBlock Text="Реестр приходных документов" Classes="muted"/>
</StackPanel>
<TextBlock Text="{Binding Totals}" HorizontalAlignment="Right" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
</Grid>
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/>
</Border>
<Border Classes="card" Padding="0" ClipToBounds="True">
<DataGrid ItemsSource="{Binding Rows}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Дата" Width="112" Binding="{Binding Date}"/>
<DataGridTextColumn Header="Номер" Width="150" Binding="{Binding Number}"/>
<DataGridTextColumn Header="Поставщик" Width="160" Binding="{Binding Supplier}"/>
<DataGridTextColumn Header="Аптека" Width="*" Binding="{Binding Location}"/>
<DataGridTextColumn Header="Позиций" Width="90" Binding="{Binding ItemsCount}" CellStyleClasses="num"/>
<DataGridTextColumn Header="Сумма" Width="132" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
</DataGrid.Columns>
</DataGrid>
</Border>
</DockPanel>
</UserControl>

View File

@ -0,0 +1,9 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Elfisa.Avalonia.Views;
public partial class InvoicesView : UserControl
{
public InvoicesView() => AvaloniaXamlLoader.Load(this);
}

View File

@ -0,0 +1,39 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.LoadPriceView"
x:DataType="vm:LoadPriceViewModel">
<Grid Margin="24" RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="Загрузить" Classes="h1"/>
<TextBlock Text="Скачать актуальный прайс с сервера" Classes="muted"/>
</StackPanel>
<Border Grid.Row="1" Classes="card" MaxWidth="560" Padding="30"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="18" HorizontalAlignment="Center">
<Border Width="60" Height="60" CornerRadius="30" Background="{DynamicResource AccentSoft}">
<TextBlock Text="📥" FontSize="26" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding LastLoaded}" Classes="muted" HorizontalAlignment="Center" TextAlignment="Center"/>
<Button Classes="primary" Content="Загрузить прайс" HorizontalAlignment="Center"
Padding="28,12" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
<StackPanel Spacing="6" IsVisible="{Binding Busy}">
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="8" Width="360"/>
<TextBlock Text="{Binding ProgressText}" Classes="muted" HorizontalAlignment="Center"/>
</StackPanel>
<Border Background="{DynamicResource AccentSoft}" CornerRadius="8" Padding="14,10"
IsVisible="{Binding Result, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Result}" Foreground="{DynamicResource AccentPressed}"
TextWrapping="Wrap" TextAlignment="Center"/>
</Border>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,9 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Elfisa.Avalonia.Views;
public partial class LoadPriceView : UserControl
{
public LoadPriceView() => AvaloniaXamlLoader.Load(this);
}

View File

@ -43,14 +43,22 @@
<TextBlock Text="{Binding SelectedOrder.Location}" Classes="muted" TextWrapping="Wrap"/>
</StackPanel>
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="0,10,0,0"
BorderThickness="0,1,0,0" BorderBrush="{DynamicResource Border}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding SelectedOrder.Sum, StringFormat='{}{0:N2}'}"
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
</Grid>
</Border>
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
<Border Padding="0,10,0,0" BorderThickness="0,1,0,0" BorderBrush="{DynamicResource Border}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding SelectedOrder.Sum, StringFormat='{}{0:N2}'}"
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
</Grid>
</Border>
<!-- Действия для черновика (не отправленного заказа) -->
<StackPanel Orientation="Horizontal" Spacing="10" IsVisible="{Binding SelectedOrder.IsDraft, FallbackValue=False}">
<Button Classes="primary" Content="Отправить заказ" Command="{Binding SendDraftCommand}" IsEnabled="{Binding !Busy}"/>
<Button Content="Удалить" Command="{Binding DeleteDraftCommand}"
Background="Transparent" Foreground="{DynamicResource Muted}"
BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/>
</StackPanel>
</StackPanel>
<DataGrid ItemsSource="{Binding SelectedOrder.Items}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"

View File

@ -0,0 +1,127 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.PriceListView"
x:DataType="vm:PriceListViewModel">
<DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Прайс-лист" Classes="h1"/>
<TextBlock Text="Каталог позиций — собери заказ и сохрани" Classes="muted"/>
</StackPanel>
</Grid>
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
</Border>
<Grid ColumnDefinitions="*,16,430">
<!-- Каталог -->
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию (от 2 символов)…"/>
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Border DockPanel.Dock="Bottom" Margin="14" Padding="0">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="Кол-во" Classes="label" VerticalAlignment="Center"/>
<NumericUpDown Value="{Binding SelectedOffer.OrderQty}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" Width="120"
IsEnabled="{Binding SelectedOffer, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
<Button Classes="primary" Content="Добавить в заказ ▸" Command="{Binding AddToOrderCommand}"/>
<TextBlock Text="или выдели строку и набери число на клавиатуре, Enter — добавить" Classes="muted" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="240"/>
</StackPanel>
</Border>
<!-- Дрилдаун как в старой программе: Название → МГ → Предложения -->
<Grid ColumnDefinitions="1.3*,10,120,10,2.2*" Margin="14,0,14,0">
<!-- Название (базовое наименование) -->
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="Название" Classes="label" Margin="2,0,0,5"/>
<ListBox ItemsSource="{Binding BaseNames}" SelectedItem="{Binding SelectedBaseName}"
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8"/>
</DockPanel>
<!-- МГ (дозировка) -->
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="МГ" Classes="label" Margin="2,0,0,5"/>
<ListBox ItemsSource="{Binding MgOptions}" SelectedItem="{Binding SelectedMg}"
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8"/>
</DockPanel>
<!-- Предложения (фильтр: название + МГ) -->
<DockPanel Grid.Column="4">
<TextBlock DockPanel.Dock="Top" Text="Предложения" Classes="label" Margin="2,0,0,5"/>
<DataGrid x:Name="OffersGrid" ItemsSource="{Binding Offers}" SelectedItem="{Binding SelectedOffer}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False"
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8">
<DataGrid.Columns>
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
<DataGridTextColumn Header="Поставщик" Width="140" Binding="{Binding SupplierName}"/>
<DataGridTextColumn Header="Цена" Width="85" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
<DataGridTextColumn Header="Остаток" Width="75" Binding="{Binding Quantity, StringFormat='{}{0:0}'}" CellStyleClasses="num"/>
<DataGridTextColumn Header="Заказ" Width="70" Binding="{Binding OrderQty, StringFormat='{}{0:0}'}" CellStyleClasses="num"
FontWeight="Bold" Foreground="{DynamicResource Accent}"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</DockPanel>
</Border>
<!-- Мой заказ -->
<Border Grid.Column="2" Classes="card" Padding="16">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Мой заказ" Classes="h2" Margin="0,0,0,10"/>
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
<StackPanel Spacing="5">
<TextBlock Text="Грузополучатель" Classes="muted"/>
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/>
</StackPanel>
<TextBox Text="{Binding Comment}" Watermark="комментарий (необязательно)"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}" FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
</Grid>
<Button Classes="primary" Content="Сохранить заказ" HorizontalAlignment="Stretch" Command="{Binding SaveDraftCommand}"/>
<TextBlock Text="Черновик появится в «Заказы» со статусом «Не отправлен»." Classes="muted" TextWrapping="Wrap"/>
</StackPanel>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Cart}" x:CompileBindings="False">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CartItem">
<Border Padding="10" Margin="0,0,0,8" CornerRadius="9"
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="✕" Width="30" Height="30" Padding="0"
VerticalAlignment="Top" Background="Transparent" Foreground="{DynamicResource Muted}" Cursor="Hand"
Command="{Binding $parent[ItemsControl].DataContext.RemoveFromCartCommand}"
CommandParameter="{Binding}"/>
<StackPanel Spacing="6">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}" TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Supplier}" Classes="muted"/>
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000" Increment="1" FormatString="0" Width="120"/>
<TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}" Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
<TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}" FontWeight="SemiBold" Foreground="{DynamicResource Text}" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</UserControl>

View File

@ -0,0 +1,58 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Elfisa.Avalonia.ViewModels;
namespace Elfisa.Avalonia.Views;
public partial class PriceListView : UserControl
{
// Строка, в которую сейчас набирают количество с клавиатуры (для многозначного ввода).
private OfferRow? _typingRow;
public PriceListView()
{
AvaloniaXamlLoader.Load(this);
var grid = this.FindControl<DataGrid>("OffersGrid");
if (grid != null) grid.KeyDown += OffersGrid_KeyDown;
}
// Выделил предложение → набираешь число цифрами (как колонка «Заказ» в старой программе).
// Backspace — стереть цифру, Enter — добавить в заказ.
private void OffersGrid_KeyDown(object? sender, KeyEventArgs e)
{
if (sender is not DataGrid grid || grid.SelectedItem is not OfferRow row) return;
var digit = DigitFromKey(e.Key);
if (digit >= 0)
{
if (!ReferenceEquals(_typingRow, row)) { row.OrderQty = 0; _typingRow = row; }
var next = row.OrderQty * 10 + digit;
if (next <= 100000) row.OrderQty = next;
e.Handled = true;
return;
}
switch (e.Key)
{
case Key.Back:
row.OrderQty = Math.Floor(row.OrderQty / 10);
_typingRow = row;
e.Handled = true;
break;
case Key.Enter:
(DataContext as PriceListViewModel)?.AddSelectedOffer();
_typingRow = null;
e.Handled = true;
break;
}
}
private static int DigitFromKey(Key k)
{
if (k >= Key.D0 && k <= Key.D9) return k - Key.D0;
if (k >= Key.NumPad0 && k <= Key.NumPad9) return k - Key.NumPad0;
return -1;
}
}

View File

@ -0,0 +1,104 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
xmlns:conv="using:Avalonia.Data.Converters"
x:Class="Elfisa.Avalonia.Views.SendOrderView"
x:DataType="vm:SendOrderViewModel">
<DockPanel Margin="24">
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Отправить заказ" Classes="h1"/>
<TextBlock Text="Найдите позиции в прайсе, соберите корзину и отправьте поставщику" Classes="muted"/>
</StackPanel>
</Grid>
<Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
</Border>
<Grid ColumnDefinitions="*,16,440">
<!-- Прайс: поиск + результаты -->
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию…"/>
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Button DockPanel.Dock="Bottom" Classes="primary" Content="Добавить в заказ ▸"
Margin="14" HorizontalAlignment="Left"
Command="{Binding AddToCartCommand}"/>
<DataGrid ItemsSource="{Binding Results}" SelectedItem="{Binding SelectedResult}"
x:CompileBindings="False" AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
<DataGridTextColumn Header="Поставщик" Width="120" Binding="{Binding SupplierName}"/>
<DataGridTextColumn Header="Цена" Width="100" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
<!-- Корзина -->
<Border Grid.Column="2" Classes="card" Padding="16">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Корзина" Classes="h2" Margin="0,0,0,10"/>
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
<StackPanel Spacing="5">
<TextBlock Text="Грузополучатель" Classes="muted"/>
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<TextBox Text="{Binding Comment}" Watermark="комментарий к заказу (необязательно)"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center"
Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}"
FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
</Grid>
<Button Classes="primary" Content="Отправить заказ" HorizontalAlignment="Stretch"
Command="{Binding SendCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Cart}" x:CompileBindings="False">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CartItem">
<Border Padding="10" Margin="0,0,0,8" CornerRadius="9"
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="✕" Width="30" Height="30" Padding="0"
VerticalAlignment="Top" Background="Transparent"
Foreground="{DynamicResource Muted}" Cursor="Hand"
Command="{Binding $parent[ItemsControl].DataContext.RemoveFromCartCommand}"
CommandParameter="{Binding}"/>
<StackPanel Spacing="6">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Supplier}" Classes="muted"/>
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000"
Increment="1" FormatString="0" Width="120"/>
<TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}"
Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
<TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}"
FontWeight="SemiBold" Foreground="{DynamicResource Text}"
VerticalAlignment="Center"/>
</Grid>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</UserControl>

View File

@ -0,0 +1,9 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Elfisa.Avalonia.Views;
public partial class SendOrderView : UserControl
{
public SendOrderView() => AvaloniaXamlLoader.Load(this);
}

View File

@ -0,0 +1,34 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
x:Class="Elfisa.Avalonia.Views.SettingsWindow"
x:DataType="vm:SettingsViewModel"
Width="460" Height="360" CanResize="False"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}"
Title="Настройки">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="Настройки" Classes="h1"/>
<StackPanel Spacing="6">
<TextBlock Text="Адрес сервера (API)" Classes="label"/>
<TextBox Text="{Binding ApiBaseUrl}" Watermark="https://24pharmdata.ru"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Тема" Classes="label"/>
<ComboBox ItemsSource="{Binding Themes}" SelectedIndex="{Binding ThemeIndex}" HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Грузополучатель по умолчанию" Classes="label"/>
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,8,0,0">
<Button Content="Отмена" Click="OnCancel" Padding="18,9"/>
<Button Classes="primary" Content="Сохранить" Click="OnSave"/>
</StackPanel>
</StackPanel>
</Window>

View File

@ -0,0 +1,31 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using Elfisa.Avalonia.ViewModels;
using Elfisa.Core;
namespace Elfisa.Avalonia.Views;
public partial class SettingsWindow : Window
{
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is SettingsViewModel vm)
{
SettingsStore.Current.ApiBaseUrl = string.IsNullOrWhiteSpace(vm.ApiBaseUrl) ? null : vm.ApiBaseUrl.Trim().TrimEnd('/');
SettingsStore.Current.Theme = vm.ThemeIndex == 1 ? "Dark" : "Light";
SettingsStore.Current.DefaultLocationId = vm.SelectedLocation?.BuyerLocationId;
SettingsStore.Save();
if (Application.Current is { } app)
app.RequestedThemeVariant = vm.ThemeIndex == 1 ? ThemeVariant.Dark : ThemeVariant.Light;
}
Close();
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close();
}

View File

@ -6,17 +6,15 @@
<DockPanel>
<!-- Верхняя панель навигации (как в исходной программе) -->
<!-- Верхняя панель навигации -->
<Border DockPanel.Dock="Top" Background="{DynamicResource Sidebar}" Height="82">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="18,0">
<!-- Логотип -->
<StackPanel Grid.Column="0" VerticalAlignment="Center" Margin="0,0,22,0">
<TextBlock Text="ЭльФиСА" FontSize="19" FontWeight="Bold" Foreground="White"/>
<TextBlock Text="Электронная Фармация" Foreground="{DynamicResource SidebarText}" FontSize="10.5"/>
<StackPanel Grid.Column="0" VerticalAlignment="Center" Margin="0,0,14,0">
<TextBlock Text="ЭльФиСА" FontSize="18" FontWeight="Bold" Foreground="White"/>
<TextBlock Text="Электронная Фармация" Foreground="{DynamicResource SidebarText}" FontSize="10"/>
</StackPanel>
<!-- Навигация -->
<ListBox Grid.Column="1" Classes="topnav" VerticalAlignment="Center" HorizontalAlignment="Left"
ItemsSource="{Binding NavItems}" SelectedItem="{Binding SelectedNav}">
<ListBox.ItemsPanel>
@ -26,18 +24,17 @@
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:NavItem">
<StackPanel Orientation="Vertical" Width="76" Spacing="3">
<TextBlock Text="{Binding Icon}" FontSize="17" HorizontalAlignment="Center"/>
<StackPanel Orientation="Vertical" Width="66" Spacing="3">
<TextBlock Text="{Binding Icon}" FontFamily="Segoe MDL2 Assets" FontSize="18"
Foreground="White" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}" Foreground="{DynamicResource SidebarText}"
FontSize="11.5" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
FontSize="11" HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Пользователь + выход -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center" Margin="18,0,0,0">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center" Margin="8,0,0,0">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding UserName}" Foreground="White" FontSize="12" FontWeight="SemiBold"
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis"/>
@ -51,7 +48,48 @@
</Grid>
</Border>
<!-- Контент -->
<ContentControl Content="{Binding CurrentPage}"/>
<!-- Строка вкладок-документов -->
<Border DockPanel.Dock="Top" Background="{DynamicResource Surface}"
BorderBrush="{DynamicResource Border}" BorderThickness="0,0,0,1" MinHeight="44" Padding="10,0">
<ListBox Classes="tabs" VerticalAlignment="Center" x:CompileBindings="False"
ItemsSource="{Binding Tabs}" SelectedItem="{Binding ActiveTab}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:DocumentTab">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" FontSize="13"/>
<Button Content="✕" FontSize="11" Width="20" Height="20" Padding="0"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Foreground="{DynamicResource Muted}"
Command="{Binding $parent[ListBox].DataContext.CloseTabCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Нижняя строка состояния -->
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Surface}"
BorderBrush="{DynamicResource Border}" BorderThickness="0,1,0,0" Height="32" Padding="16,0">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Text="Пользователь:" Classes="muted" FontSize="12"/>
<TextBlock Text="{Binding UserName}" FontSize="12" Foreground="{DynamicResource Text}"/>
<TextBlock Text=" | Аптека:" Classes="muted" FontSize="12"/>
<TextBlock Text="{Binding PharmacyAddress}" FontSize="12" Foreground="{DynamicResource Text}" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<Button Grid.Column="1" Content="Настройки" Click="OnSettingsClick"
Background="Transparent" Foreground="{DynamicResource Accent}" BorderThickness="0"
Padding="10,4" FontSize="12.5" Cursor="Hand"/>
</Grid>
</Border>
<!-- Контент активной вкладки -->
<ContentControl Content="{Binding ActiveTab.Content}"/>
</DockPanel>
</UserControl>

View File

@ -1,9 +1,21 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Elfisa.Avalonia.ViewModels;
namespace Elfisa.Avalonia.Views;
public partial class ShellView : UserControl
{
public ShellView() => AvaloniaXamlLoader.Load(this);
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
{
var win = new SettingsWindow { DataContext = new SettingsViewModel() };
var owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (owner is not null) await win.ShowDialog(owner);
else win.Show();
}
}

View File

@ -97,6 +97,60 @@ public sealed class ApiClient
return new AiReportResult { Content = bytes, FileName = name.Trim('"') };
}
/// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary>
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0)
{
EnsureAuth();
var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}";
if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2)
path += "&q=" + Uri.EscapeDataString(q.Trim());
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
}
/// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary>
public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request)
{
EnsureAuth();
if (request.Items.Count == 0) throw new ApiException("Корзина пуста.");
var body = JsonSerializer.Serialize(request, Json);
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/api/buyer/orders"))
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
HttpResponseMessage resp;
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
catch (Exception ex) { throw new ApiException($"Не удалось отправить заказ: {ex.Message}", ex); }
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка создания заказа ({(int)resp.StatusCode}).");
return JsonSerializer.Deserialize<BuyerOrderResponse>(text, Json) ?? new BuyerOrderResponse();
}
/// <summary>Резервирует следующий номер заказа EX-####### (как в WinForms до создания заказа).</summary>
public async Task<string?> NextGlobalSignAsync()
{
EnsureAuth();
var text = await AuthorizedGetAsync("/api/buyer/global-sign/next", "резервирование номера").ConfigureAwait(false);
try
{
using var doc = JsonDocument.Parse(text);
return doc.RootElement.TryGetProperty("global_sign", out var v) ? v.GetString() : null;
}
catch { return null; }
}
/// <summary>Адреса-грузополучатели покупателя.</summary>
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
{
EnsureAuth();
var text = await AuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей").ConfigureAwait(false);
try { return JsonSerializer.Deserialize<List<BuyerLocationDto>>(text, Json) ?? new(); }
catch { return new(); }
}
private async Task<string> AuthorizedGetAsync(string path, string op)
{
using var req = new HttpRequestMessage(HttpMethod.Get, Url(path));

View File

@ -17,6 +17,8 @@ public static class AppConfig
{
get
{
var fromSettings = SettingsStore.Current.ApiBaseUrl;
if (!string.IsNullOrWhiteSpace(fromSettings)) return fromSettings.Trim().TrimEnd('/');
var env = Environment.GetEnvironmentVariable(ApiEnv);
return string.IsNullOrWhiteSpace(env) ? DefaultApiBaseUrl : env.Trim().TrimEnd('/');
}

View File

@ -0,0 +1,75 @@
using System.Text.Json;
namespace Elfisa.Core;
public sealed class DraftItem
{
public string SupplierPriceId { get; set; } = "";
public string Name { get; set; } = "";
public string Supplier { get; set; } = "";
public string? Barcode { get; set; }
public decimal Price { get; set; }
public decimal Qty { get; set; } = 1;
public decimal LineSum => Price * Qty;
}
public sealed class DraftOrder
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Number { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.Now;
public string? LocationId { get; set; }
public string? LocationAddress { get; set; }
public string? Comment { get; set; }
public List<DraftItem> Items { get; set; } = new();
public decimal Total => Items.Sum(i => i.LineSum);
public int ItemsCount => Items.Count;
}
/// <summary>Локальные черновики заказов (%APPDATA%\ElfisaAvalonia\drafts.json).</summary>
public static class DraftStore
{
private sealed class Box { public List<DraftOrder> Drafts { get; set; } = new(); }
private static readonly string Dir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ElfisaAvalonia");
private static readonly string File = Path.Combine(Dir, "drafts.json");
private static Box _box = Load();
public static IReadOnlyList<DraftOrder> All => _box.Drafts;
private static Box Load()
{
try
{
if (System.IO.File.Exists(File))
return JsonSerializer.Deserialize<Box>(System.IO.File.ReadAllText(File)) ?? new Box();
}
catch { }
return new Box();
}
private static void Save()
{
try
{
Directory.CreateDirectory(Dir);
System.IO.File.WriteAllText(File, JsonSerializer.Serialize(_box, new JsonSerializerOptions { WriteIndented = true }));
}
catch { }
}
public static DraftOrder Add(DraftOrder draft)
{
_box.Drafts.Insert(0, draft);
Save();
return draft;
}
public static void Remove(string id)
{
_box.Drafts.RemoveAll(d => d.Id == id);
Save();
}
}

View File

@ -52,3 +52,57 @@ public sealed class ErrorResponse
{
[JsonPropertyName("error")] public string? Error { get; set; }
}
public sealed class PriceSummaryResponse
{
[JsonPropertyName("summary")] public List<PriceItem>? Summary { get; set; }
[JsonPropertyName("total_drugs")] public int TotalDrugs { get; set; }
}
public sealed class PriceItem
{
[JsonPropertyName("supplier_price_id")] public string? SupplierPriceId { get; set; }
[JsonPropertyName("supplier_name")] public string? SupplierName { get; set; }
[JsonPropertyName("drug_name")] public string? DrugName { get; set; }
[JsonPropertyName("cure_form")] public string? CureForm { get; set; }
[JsonPropertyName("barcode")] public string? Barcode { get; set; }
[JsonPropertyName("price")] public decimal Price { get; set; }
[JsonPropertyName("quantity")] public decimal Quantity { get; set; }
[JsonPropertyName("manufacturer")] public string? Manufacturer { get; set; }
[JsonPropertyName("trade_name")] public string? TradeName { get; set; }
[JsonPropertyName("es_code")] public long EsCode { get; set; }
}
public sealed class BuyerLocationDto
{
[JsonPropertyName("buyer_location_id")] public string? BuyerLocationId { get; set; }
[JsonPropertyName("address")] public string? Address { get; set; }
[JsonPropertyName("region_name")] public string? RegionName { get; set; }
[JsonPropertyName("is_default")] public bool IsDefault { get; set; }
public override string ToString() => Address ?? "";
}
public sealed class BuyerOrderItemReq
{
[JsonPropertyName("supplier_price_id")] public string SupplierPriceId { get; set; } = "";
[JsonPropertyName("qty")] public double Qty { get; set; }
[JsonPropertyName("item_name")] public string? ItemName { get; set; }
[JsonPropertyName("barcode")] public string? Barcode { get; set; }
}
public sealed class BuyerOrderCreateRequest
{
[JsonPropertyName("location_id")] public string? LocationId { get; set; }
[JsonPropertyName("comment")] public string? Comment { get; set; }
[JsonPropertyName("global_sign")] public string? GlobalSign { get; set; }
[JsonPropertyName("items")] public List<BuyerOrderItemReq> Items { get; set; } = new();
}
public sealed class BuyerOrderResponse
{
[JsonPropertyName("order_id")] public string? OrderId { get; set; }
[JsonPropertyName("status")] public string? Status { get; set; }
[JsonPropertyName("global_sign")] public string? GlobalSign { get; set; }
[JsonPropertyName("total_amount")] public decimal TotalAmount { get; set; }
[JsonPropertyName("items_count")] public int ItemsCount { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace Elfisa.Core;
/// <summary>Загруженный прайс в памяти (наполняется разделом «Загрузить»).</summary>
public static class PriceCache
{
public static List<PriceItem> Items { get; set; } = new();
public static DateTime? LoadedAt { get; set; }
public static bool HasData => Items.Count > 0;
}

View File

@ -0,0 +1,44 @@
using System.Text.Json;
namespace Elfisa.Core;
public sealed class AppSettings
{
public string? ApiBaseUrl { get; set; }
public string Theme { get; set; } = "Light"; // Light | Dark
public string? DefaultLocationId { get; set; }
public string? Token { get; set; } // сохранённая сессия
public string? Role { get; set; }
public string? Username { get; set; }
}
/// <summary>Настройки приложения в %APPDATA%\ElfisaAvalonia\settings.json.</summary>
public static class SettingsStore
{
private static readonly string Dir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ElfisaAvalonia");
private static readonly string File = Path.Combine(Dir, "settings.json");
public static AppSettings Current { get; private set; } = Load();
public static AppSettings Load()
{
try
{
if (System.IO.File.Exists(File))
return JsonSerializer.Deserialize<AppSettings>(System.IO.File.ReadAllText(File)) ?? new AppSettings();
}
catch { /* повреждённый файл — начинаем с чистых настроек */ }
return new AppSettings();
}
public static void Save()
{
try
{
Directory.CreateDirectory(Dir);
System.IO.File.WriteAllText(File, JsonSerializer.Serialize(Current, new JsonSerializerOptions { WriteIndented = true }));
}
catch { /* нет прав/диска — молча пропускаем */ }
}
}