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

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

View File

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@ -8,8 +10,23 @@ using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels; 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>
/// Прайс-лист + сборка заказа: слева каталог, справа «Мой заказ» (общая корзина). /// Прайс-лист + сборка заказа. Каталог — как в старой программе:
/// база (название) → дозировка (МГ) → предложения поставщиков. Справа «Мой заказ».
/// «Сохранить заказ» кладёт черновик в «Заказы» (статус «Не отправлен»). /// «Сохранить заказ» кладёт черновик в «Заказы» (статус «Не отправлен»).
/// </summary> /// </summary>
public partial class PriceListViewModel : ViewModelBase public partial class PriceListViewModel : ViewModelBase
@ -17,13 +34,26 @@ public partial class PriceListViewModel : ViewModelBase
private readonly ApiClient _api = new(); private readonly ApiClient _api = new();
private readonly CartService _cart = CartService.Instance; private readonly CartService _cart = CartService.Instance;
public ObservableCollection<PriceItem> Items { get; } = new(); // Плоский список предложений + предвычисленные база/МГ (как «Базовое наименование»/«МГ» в 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<CartItem> Cart => _cart.Items;
public ObservableCollection<BuyerLocationDto> Locations { get; } = new(); public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
[ObservableProperty] private string _search = ""; [ObservableProperty] private string _search = "";
[ObservableProperty] private PriceItem? _selectedItem; [ObservableProperty] private string? _selectedBaseName;
[ObservableProperty] private decimal _qty = 1; [ObservableProperty] private string? _selectedMg;
[ObservableProperty] private OfferRow? _selectedOffer;
[ObservableProperty] private BuyerLocationDto? _selectedLocation; [ObservableProperty] private BuyerLocationDto? _selectedLocation;
[ObservableProperty] private string _comment = ""; [ObservableProperty] private string _comment = "";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
@ -68,19 +98,115 @@ public partial class PriceListViewModel : ViewModelBase
{ {
Busy = true; Busy = true;
var resp = await _api.GetPriceSummaryAsync(Search, limit: 300); var resp = await _api.GetPriceSummaryAsync(Search, limit: 300);
Items.Clear(); RebuildIndex(resp.Summary ?? new());
foreach (var it in resp.Summary ?? new()) Items.Add(it);
Total = resp.TotalDrugs; Total = resp.TotalDrugs;
} }
catch (Exception ex) { Status = ex.Message; } catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; } finally { Busy = false; }
} }
[RelayCommand] /// <summary>Пересобирает индекс база/МГ и список базовых наименований.</summary>
private void AddToOrder() private void RebuildIndex(List<PriceItem> items)
{ {
if (SelectedItem is null) { Status = "Выберите позицию в списке."; return; } _indexed.Clear();
_cart.Add(SelectedItem, Qty <= 0 ? 1 : Qty); 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; Status = null;
} }

View File

@ -30,21 +30,49 @@
</StackPanel> </StackPanel>
<Border DockPanel.Dock="Bottom" Margin="14" Padding="0"> <Border DockPanel.Dock="Bottom" Margin="14" Padding="0">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<TextBlock Text="Кол-во" Classes="label"/> <TextBlock Text="Кол-во" Classes="label" VerticalAlignment="Center"/>
<NumericUpDown Value="{Binding Qty}" Minimum="1" Maximum="100000" Increment="1" FormatString="0" Width="120"/> <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}"/> <Button Classes="primary" Content="Добавить в заказ ▸" Command="{Binding AddToOrderCommand}"/>
<TextBlock Text="или выдели строку и набери число на клавиатуре, Enter — добавить" Classes="muted" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="240"/>
</StackPanel> </StackPanel>
</Border> </Border>
<DataGrid ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem}" x:CompileBindings="False" <!-- Дрилдаун как в старой программе: Название → МГ → Предложения -->
<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" AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False"> CanUserResizeColumns="True" CanUserSortColumns="False"
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/> <DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
<DataGridTextColumn Header="Производитель" Width="180" Binding="{Binding Manufacturer}"/> <DataGridTextColumn Header="Поставщик" Width="140" Binding="{Binding SupplierName}"/>
<DataGridTextColumn Header="Цена" Width="100" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/> <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.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
</Grid>
</DockPanel>
</Border> </Border>
<!-- Мой заказ --> <!-- Мой заказ -->

View File

@ -1,9 +1,58 @@
using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Elfisa.Avalonia.ViewModels;
namespace Elfisa.Avalonia.Views; namespace Elfisa.Avalonia.Views;
public partial class PriceListView : UserControl public partial class PriceListView : UserControl
{ {
public PriceListView() => AvaloniaXamlLoader.Load(this); // Строка, в которую сейчас набирают количество с клавиатуры (для многозначного ввода).
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;
}
} }