Compare commits
8 Commits
132ce1531f
...
3fc825e54b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc825e54b | ||
|
|
0536d480cb | ||
|
|
cd2fb57bca | ||
|
|
2420a71ace | ||
|
|
c677345ba6 | ||
|
|
43468ac693 | ||
|
|
b13a867f8d | ||
|
|
77dfb08ef4 |
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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}"/>
|
||||
|
||||
71
src/Elfisa.Avalonia/ViewModels/CartService.cs
Normal file
71
src/Elfisa.Avalonia/ViewModels/CartService.cs
Normal 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();
|
||||
}
|
||||
48
src/Elfisa.Avalonia/ViewModels/DirectoryViewModel.cs
Normal file
48
src/Elfisa.Avalonia/ViewModels/DirectoryViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
72
src/Elfisa.Avalonia/ViewModels/InvoicesViewModel.cs
Normal file
72
src/Elfisa.Avalonia/ViewModels/InvoicesViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
78
src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs
Normal file
78
src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -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 = "Черновик удалён.";
|
||||
}
|
||||
}
|
||||
|
||||
250
src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs
Normal file
250
src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
106
src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs
Normal file
106
src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
43
src/Elfisa.Avalonia/ViewModels/SettingsViewModel.cs
Normal file
43
src/Elfisa.Avalonia/ViewModels/SettingsViewModel.cs
Normal 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 { /* локации не критичны для настроек */ }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
56
src/Elfisa.Avalonia/Views/DirectoryView.axaml
Normal file
56
src/Elfisa.Avalonia/Views/DirectoryView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/DirectoryView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/DirectoryView.axaml.cs
Normal 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);
|
||||
}
|
||||
39
src/Elfisa.Avalonia/Views/InvoicesView.axaml
Normal file
39
src/Elfisa.Avalonia/Views/InvoicesView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/InvoicesView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/InvoicesView.axaml.cs
Normal 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);
|
||||
}
|
||||
39
src/Elfisa.Avalonia/Views/LoadPriceView.axaml
Normal file
39
src/Elfisa.Avalonia/Views/LoadPriceView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/LoadPriceView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/LoadPriceView.axaml.cs
Normal 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);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
127
src/Elfisa.Avalonia/Views/PriceListView.axaml
Normal file
127
src/Elfisa.Avalonia/Views/PriceListView.axaml
Normal 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>
|
||||
58
src/Elfisa.Avalonia/Views/PriceListView.axaml.cs
Normal file
58
src/Elfisa.Avalonia/Views/PriceListView.axaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
104
src/Elfisa.Avalonia/Views/SendOrderView.axaml
Normal file
104
src/Elfisa.Avalonia/Views/SendOrderView.axaml
Normal 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>
|
||||
9
src/Elfisa.Avalonia/Views/SendOrderView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/SendOrderView.axaml.cs
Normal 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);
|
||||
}
|
||||
34
src/Elfisa.Avalonia/Views/SettingsWindow.axaml
Normal file
34
src/Elfisa.Avalonia/Views/SettingsWindow.axaml
Normal 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>
|
||||
31
src/Elfisa.Avalonia/Views/SettingsWindow.axaml.cs
Normal file
31
src/Elfisa.Avalonia/Views/SettingsWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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('/');
|
||||
}
|
||||
|
||||
75
src/Elfisa.Core/DraftStore.cs
Normal file
75
src/Elfisa.Core/DraftStore.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
9
src/Elfisa.Core/PriceCache.cs
Normal file
9
src/Elfisa.Core/PriceCache.cs
Normal 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;
|
||||
}
|
||||
44
src/Elfisa.Core/SettingsStore.cs
Normal file
44
src/Elfisa.Core/SettingsStore.cs
Normal 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 { /* нет прав/диска — молча пропускаем */ }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user