������� �� Avalonia 2.0 (�����-���������, ����������, �������� ��������) #1

Merged
Exest merged 18 commits from feature/avalonia-ui into master 2026-08-11 09:41:09 +03:00
12 changed files with 285 additions and 50 deletions
Showing only changes of commit c677345ba6 - Show all commits

View File

@ -18,6 +18,10 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted() 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) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow

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

@ -7,24 +7,33 @@ using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels; namespace Elfisa.Avalonia.ViewModels;
/// <summary>Прайс-лист (сводный прайс с /api/supplier-prices/summary — как в WinForms).</summary> /// <summary>Прайс-лист (сводный прайс) + добавление позиций в заказ (общая корзина).</summary>
public partial class PriceListViewModel : ViewModelBase public partial class PriceListViewModel : ViewModelBase
{ {
private readonly ApiClient _api = new(); private readonly ApiClient _api = new();
private readonly CartService _cart = CartService.Instance;
public ObservableCollection<PriceItem> Items { get; } = new(); public ObservableCollection<PriceItem> Items { get; } = new();
[ObservableProperty] private string _search = ""; [ObservableProperty] private string _search = "";
[ObservableProperty] private PriceItem? _selectedItem;
[ObservableProperty] private decimal _qty = 1;
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status; [ObservableProperty] private string? _status;
[ObservableProperty] private int _total; [ObservableProperty] private int _total;
[ObservableProperty] private string _cartInfo = "";
public PriceListViewModel() public PriceListViewModel()
{ {
_api.SetToken(Session.Current.Token); _api.SetToken(Session.Current.Token);
_cart.Changed += UpdateCartInfo;
UpdateCartInfo();
if (Session.Current.IsAuthenticated) _ = SearchAsync(); if (Session.Current.IsAuthenticated) _ = SearchAsync();
} }
private void UpdateCartInfo() =>
CartInfo = _cart.Count == 0 ? "Корзина пуста" : $"В заказе: {_cart.Count} поз. · {_cart.Total:N2}";
[RelayCommand] [RelayCommand]
private async Task SearchAsync() private async Task SearchAsync()
{ {
@ -42,4 +51,13 @@ public partial class PriceListViewModel : ViewModelBase
catch (Exception ex) { Status = ex.Message; } catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; } finally { Busy = false; }
} }
[RelayCommand]
private void AddToOrder()
{
if (SelectedItem is null) { Status = "Выберите позицию в списке."; return; }
var q = Qty <= 0 ? 1 : Qty;
_cart.Add(SelectedItem, q);
Status = $"Добавлено в заказ: {SelectedItem.DrugName} × {q:N0}";
}
} }

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@ -9,37 +8,17 @@ using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels; 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>
/// Отправить: поиск по прайсу → корзина → создание заказа (POST /api/buyer/orders). /// Отправить: поиск по прайсу → корзина (общая с Прайс-листом) → создание заказа.
/// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика. /// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
/// </summary> /// </summary>
public partial class SendOrderViewModel : ViewModelBase public partial class SendOrderViewModel : ViewModelBase
{ {
private readonly ApiClient _api = new(); private readonly ApiClient _api = new();
private readonly CartService _cart = CartService.Instance;
public ObservableCollection<PriceItem> Results { get; } = new(); public ObservableCollection<PriceItem> Results { get; } = new();
public ObservableCollection<CartItem> Cart { get; } = new(); public ObservableCollection<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new(); public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private string _search = ""; [ObservableProperty] private string _search = "";
@ -53,12 +32,11 @@ public partial class SendOrderViewModel : ViewModelBase
public SendOrderViewModel() public SendOrderViewModel()
{ {
_api.SetToken(Session.Current.Token); _api.SetToken(Session.Current.Token);
Cart.CollectionChanged += OnCartChanged; _cart.Changed += Recalc;
Recalc();
if (Session.Current.IsAuthenticated) _ = InitAsync(); if (Session.Current.IsAuthenticated) _ = InitAsync();
} }
private void OnCartChanged(object? s, NotifyCollectionChangedEventArgs e) => Recalc();
private async Task InitAsync() private async Task InitAsync()
{ {
try try
@ -90,33 +68,19 @@ public partial class SendOrderViewModel : ViewModelBase
private void AddToCart() private void AddToCart()
{ {
if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; } if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; }
var existing = Cart.FirstOrDefault(c => c.Source.SupplierPriceId == SelectedResult.SupplierPriceId); _cart.Add(SelectedResult);
if (existing != null) { existing.Qty += 1; }
else
{
var ci = new CartItem(SelectedResult);
ci.Changed += Recalc;
Cart.Add(ci);
}
Recalc();
} }
[RelayCommand] [RelayCommand]
private void RemoveFromCart(CartItem? item) private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
{
if (item is null) return;
item.Changed -= Recalc;
Cart.Remove(item);
Recalc();
}
private void Recalc() => CartTotal = Cart.Sum(c => c.LineSum); private void Recalc() => CartTotal = _cart.Total;
[RelayCommand] [RelayCommand]
private async Task SendAsync() private async Task SendAsync()
{ {
Status = null; Status = null;
if (Cart.Count == 0) { Status = "Корзина пуста."; return; } if (_cart.Count == 0) { Status = "Корзина пуста."; return; }
try try
{ {
Busy = true; Busy = true;
@ -124,7 +88,7 @@ public partial class SendOrderViewModel : ViewModelBase
{ {
LocationId = SelectedLocation?.BuyerLocationId, LocationId = SelectedLocation?.BuyerLocationId,
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(), Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
Items = Cart.Select(c => new BuyerOrderItemReq Items = _cart.Items.Select(c => new BuyerOrderItemReq
{ {
SupplierPriceId = c.Source.SupplierPriceId ?? "", SupplierPriceId = c.Source.SupplierPriceId ?? "",
Qty = (double)c.Qty, Qty = (double)c.Qty,
@ -134,8 +98,7 @@ public partial class SendOrderViewModel : ViewModelBase
}; };
var resp = await _api.CreateOrderAsync(req); var resp = await _api.CreateOrderAsync(req);
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} создан: {resp.ItemsCount} поз., {resp.TotalAmount:N2}."; Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} создан: {resp.ItemsCount} поз., {resp.TotalAmount:N2}.";
Cart.Clear(); _cart.Clear();
Recalc();
} }
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; } catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
finally { Busy = false; } 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

@ -27,8 +27,17 @@
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/> <TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/>
</Border> </Border>
<Border DockPanel.Dock="Bottom" Classes="card" Margin="0,10,0,0" Padding="14">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Text="Кол-во" Classes="label"/>
<NumericUpDown Value="{Binding Qty}" Minimum="1" Maximum="100000" Increment="1" FormatString="0" Width="130"/>
<Button Classes="primary" Content="Добавить в заказ ▸" Command="{Binding AddToOrderCommand}"/>
<TextBlock Text="{Binding CartInfo}" Classes="muted" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</Border>
<Border Classes="card" Padding="0" ClipToBounds="True"> <Border Classes="card" Padding="0" ClipToBounds="True">
<DataGrid ItemsSource="{Binding Items}" x:CompileBindings="False" <DataGrid ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column" AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False"> CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns> <DataGrid.Columns>

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

@ -44,6 +44,10 @@
<TextBlock Text="{Binding RoleLabel}" Foreground="{DynamicResource SidebarText}" <TextBlock Text="{Binding RoleLabel}" Foreground="{DynamicResource SidebarText}"
FontSize="10.5" HorizontalAlignment="Right"/> FontSize="10.5" HorizontalAlignment="Right"/>
</StackPanel> </StackPanel>
<Button Content="⚙" Click="OnSettingsClick" ToolTip.Tip="Настройки"
Background="Transparent" Foreground="White" BorderBrush="#55FFFFFF"
BorderThickness="1" CornerRadius="8" Width="40" Height="38" Padding="0"
FontSize="16" Cursor="Hand"/>
<Button Content="Выйти" Command="{Binding LogoutCommand}" <Button Content="Выйти" Command="{Binding LogoutCommand}"
Background="Transparent" Foreground="White" BorderBrush="#55FFFFFF" Background="Transparent" Foreground="White" BorderBrush="#55FFFFFF"
BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/> BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/>

View File

@ -1,9 +1,21 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Elfisa.Avalonia.ViewModels;
namespace Elfisa.Avalonia.Views; namespace Elfisa.Avalonia.Views;
public partial class ShellView : UserControl public partial class ShellView : UserControl
{ {
public ShellView() => AvaloniaXamlLoader.Load(this); 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

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

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 { /* нет прав/диска — молча пропускаем */ }
}
}