Avalonia: общая корзина, добавление из Прайс-листа, Настройки
- CartService: единая корзина для Прайс-листа и Отправить - Прайс-лист: выбор позиции + кол-во + «Добавить в заказ», индикатор корзины - Настройки (диалог ⚙ в шапке): адрес API, тема (свет/тьма, применяется сразу), грузополучатель по умолчанию; сохранение в %APPDATA% - SettingsStore (Core) + AppConfig читает сохранённый API-адрес - тема применяется на старте Проверено вживую: добавление из Прайс-листа в корзину, диалог настроек. Реальная отправка заказа (POST) — собрана и связана, живой заказ по явному «да» (автозащита блокирует боевую транзакцию без подтверждения). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
43468ac693
commit
c677345ba6
@ -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
|
||||
|
||||
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();
|
||||
}
|
||||
@ -7,24 +7,33 @@ using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
/// <summary>Прайс-лист (сводный прайс с /api/supplier-prices/summary — как в WinForms).</summary>
|
||||
/// <summary>Прайс-лист (сводный прайс) + добавление позиций в заказ (общая корзина).</summary>
|
||||
public partial class PriceListViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ApiClient _api = new();
|
||||
private readonly CartService _cart = CartService.Instance;
|
||||
|
||||
public ObservableCollection<PriceItem> Items { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _search = "";
|
||||
[ObservableProperty] private PriceItem? _selectedItem;
|
||||
[ObservableProperty] private decimal _qty = 1;
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private string? _status;
|
||||
[ObservableProperty] private int _total;
|
||||
[ObservableProperty] private string _cartInfo = "";
|
||||
|
||||
public PriceListViewModel()
|
||||
{
|
||||
_api.SetToken(Session.Current.Token);
|
||||
_cart.Changed += UpdateCartInfo;
|
||||
UpdateCartInfo();
|
||||
if (Session.Current.IsAuthenticated) _ = SearchAsync();
|
||||
}
|
||||
|
||||
private void UpdateCartInfo() =>
|
||||
CartInfo = _cart.Count == 0 ? "Корзина пуста" : $"В заказе: {_cart.Count} поз. · {_cart.Total:N2}";
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SearchAsync()
|
||||
{
|
||||
@ -42,4 +51,13 @@ public partial class PriceListViewModel : ViewModelBase
|
||||
catch (Exception ex) { Status = ex.Message; }
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@ -9,37 +8,17 @@ 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>
|
||||
/// Отправить: поиск по прайсу → корзина → создание заказа (POST /api/buyer/orders).
|
||||
/// Отправить: поиск по прайсу → корзина (общая с Прайс-листом) → создание заказа.
|
||||
/// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
|
||||
/// </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 { get; } = new();
|
||||
public ObservableCollection<CartItem> Cart => _cart.Items;
|
||||
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _search = "";
|
||||
@ -53,12 +32,11 @@ public partial class SendOrderViewModel : ViewModelBase
|
||||
public SendOrderViewModel()
|
||||
{
|
||||
_api.SetToken(Session.Current.Token);
|
||||
Cart.CollectionChanged += OnCartChanged;
|
||||
_cart.Changed += Recalc;
|
||||
Recalc();
|
||||
if (Session.Current.IsAuthenticated) _ = InitAsync();
|
||||
}
|
||||
|
||||
private void OnCartChanged(object? s, NotifyCollectionChangedEventArgs e) => Recalc();
|
||||
|
||||
private async Task InitAsync()
|
||||
{
|
||||
try
|
||||
@ -90,33 +68,19 @@ public partial class SendOrderViewModel : ViewModelBase
|
||||
private void AddToCart()
|
||||
{
|
||||
if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; }
|
||||
var existing = Cart.FirstOrDefault(c => c.Source.SupplierPriceId == SelectedResult.SupplierPriceId);
|
||||
if (existing != null) { existing.Qty += 1; }
|
||||
else
|
||||
{
|
||||
var ci = new CartItem(SelectedResult);
|
||||
ci.Changed += Recalc;
|
||||
Cart.Add(ci);
|
||||
}
|
||||
Recalc();
|
||||
_cart.Add(SelectedResult);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveFromCart(CartItem? item)
|
||||
{
|
||||
if (item is null) return;
|
||||
item.Changed -= Recalc;
|
||||
Cart.Remove(item);
|
||||
Recalc();
|
||||
}
|
||||
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
|
||||
|
||||
private void Recalc() => CartTotal = Cart.Sum(c => c.LineSum);
|
||||
private void Recalc() => CartTotal = _cart.Total;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SendAsync()
|
||||
{
|
||||
Status = null;
|
||||
if (Cart.Count == 0) { Status = "Корзина пуста."; return; }
|
||||
if (_cart.Count == 0) { Status = "Корзина пуста."; return; }
|
||||
try
|
||||
{
|
||||
Busy = true;
|
||||
@ -124,7 +88,7 @@ public partial class SendOrderViewModel : ViewModelBase
|
||||
{
|
||||
LocationId = SelectedLocation?.BuyerLocationId,
|
||||
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
|
||||
Items = Cart.Select(c => new BuyerOrderItemReq
|
||||
Items = _cart.Items.Select(c => new BuyerOrderItemReq
|
||||
{
|
||||
SupplierPriceId = c.Source.SupplierPriceId ?? "",
|
||||
Qty = (double)c.Qty,
|
||||
@ -134,8 +98,7 @@ public partial class SendOrderViewModel : ViewModelBase
|
||||
};
|
||||
var resp = await _api.CreateOrderAsync(req);
|
||||
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} создан: {resp.ItemsCount} поз., {resp.TotalAmount:N2}.";
|
||||
Cart.Clear();
|
||||
Recalc();
|
||||
_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 { /* локации не критичны для настроек */ }
|
||||
}
|
||||
}
|
||||
@ -27,8 +27,17 @@
|
||||
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/>
|
||||
</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">
|
||||
<DataGrid ItemsSource="{Binding Items}" x:CompileBindings="False"
|
||||
<DataGrid ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem}" x:CompileBindings="False"
|
||||
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||
<DataGrid.Columns>
|
||||
|
||||
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();
|
||||
}
|
||||
@ -44,6 +44,10 @@
|
||||
<TextBlock Text="{Binding RoleLabel}" Foreground="{DynamicResource SidebarText}"
|
||||
FontSize="10.5" HorizontalAlignment="Right"/>
|
||||
</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}"
|
||||
Background="Transparent" Foreground="White" BorderBrush="#55FFFFFF"
|
||||
BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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('/');
|
||||
}
|
||||
|
||||
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