������� �� Avalonia 2.0 (�����-���������, ����������, �������� ��������) #1
@ -1,6 +1,8 @@
|
||||
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;
|
||||
@ -8,8 +10,23 @@ 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
|
||||
@ -17,13 +34,26 @@ public partial class PriceListViewModel : ViewModelBase
|
||||
private readonly ApiClient _api = new();
|
||||
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<BuyerLocationDto> Locations { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _search = "";
|
||||
[ObservableProperty] private PriceItem? _selectedItem;
|
||||
[ObservableProperty] private decimal _qty = 1;
|
||||
[ObservableProperty] private string? _selectedBaseName;
|
||||
[ObservableProperty] private string? _selectedMg;
|
||||
[ObservableProperty] private OfferRow? _selectedOffer;
|
||||
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
|
||||
[ObservableProperty] private string _comment = "";
|
||||
[ObservableProperty] private bool _busy;
|
||||
@ -68,19 +98,115 @@ public partial class PriceListViewModel : ViewModelBase
|
||||
{
|
||||
Busy = true;
|
||||
var resp = await _api.GetPriceSummaryAsync(Search, limit: 300);
|
||||
Items.Clear();
|
||||
foreach (var it in resp.Summary ?? new()) Items.Add(it);
|
||||
RebuildIndex(resp.Summary ?? new());
|
||||
Total = resp.TotalDrugs;
|
||||
}
|
||||
catch (Exception ex) { Status = ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddToOrder()
|
||||
/// <summary>Пересобирает индекс база/МГ и список базовых наименований.</summary>
|
||||
private void RebuildIndex(List<PriceItem> items)
|
||||
{
|
||||
if (SelectedItem is null) { Status = "Выберите позицию в списке."; return; }
|
||||
_cart.Add(SelectedItem, Qty <= 0 ? 1 : Qty);
|
||||
_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;
|
||||
}
|
||||
|
||||
|
||||
@ -30,21 +30,49 @@
|
||||
</StackPanel>
|
||||
<Border DockPanel.Dock="Bottom" Margin="14" Padding="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<TextBlock Text="Кол-во" Classes="label"/>
|
||||
<NumericUpDown Value="{Binding Qty}" Minimum="1" Maximum="100000" Increment="1" FormatString="0" Width="120"/>
|
||||
<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>
|
||||
<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"
|
||||
CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||
CanUserResizeColumns="True" CanUserSortColumns="False"
|
||||
BorderThickness="1" BorderBrush="{DynamicResource Border}" CornerRadius="8">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
|
||||
<DataGridTextColumn Header="Производитель" Width="180" Binding="{Binding Manufacturer}"/>
|
||||
<DataGridTextColumn Header="Цена" Width="100" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||
<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>
|
||||
|
||||
<!-- Мой заказ -->
|
||||
|
||||
@ -1,9 +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
|
||||
{
|
||||
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