Avalonia: Прайс-лист как в WinForms — дрилдаун Название→МГ→Предложения + ввод количества с клавиатуры
- База/дозировка вычисляются на клиенте (порт BuildBaseName/ExtractDosage) - Три панели: базовые наименования → дозировки (МГ) → предложения поставщиков - Колонка «Заказ»: ввод числа с клавиатуры (многозначно), Enter — добавить - Фикс: null-безопасная привязка KeyDown (падало в конструкторе → пустой экран) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0536d480cb
commit
3fc825e54b
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,20 +30,48 @@
|
|||||||
</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"
|
<!-- Дрилдаун как в старой программе: Название → МГ → Предложения -->
|
||||||
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
|
<Grid ColumnDefinitions="1.3*,10,120,10,2.2*" Margin="14,0,14,0">
|
||||||
CanUserResizeColumns="True" CanUserSortColumns="False">
|
|
||||||
<DataGrid.Columns>
|
<!-- Название (базовое наименование) -->
|
||||||
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
|
<DockPanel Grid.Column="0">
|
||||||
<DataGridTextColumn Header="Производитель" Width="180" Binding="{Binding Manufacturer}"/>
|
<TextBlock DockPanel.Dock="Top" Text="Название" Classes="label" Margin="2,0,0,5"/>
|
||||||
<DataGridTextColumn Header="Цена" Width="100" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
<ListBox ItemsSource="{Binding BaseNames}" SelectedItem="{Binding SelectedBaseName}"
|
||||||
</DataGrid.Columns>
|
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8"/>
|
||||||
</DataGrid>
|
</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>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user