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

Merged
Exest merged 18 commits from feature/avalonia-ui into master 2026-08-11 09:41:09 +03:00
5 changed files with 313 additions and 65 deletions
Showing only changes of commit 2420a71ace - Show all commits

View File

@ -15,6 +15,8 @@ public sealed class OrderItemRow
public decimal Qty { get; init; }
public decimal Price { get; init; }
public decimal Sum { get; init; }
public string? SupplierPriceId { get; init; }
public string? Barcode { get; init; }
}
public sealed class OrderSummary
@ -27,12 +29,16 @@ public sealed class OrderSummary
public int ItemsCount { get; init; }
public decimal Sum { get; init; }
public ObservableCollection<OrderItemRow> Items { get; init; } = new();
public bool IsDraft { get; init; }
public string? DraftId { get; init; }
public string? LocationId { get; init; }
public string? Comment { get; init; }
}
/// <summary>
/// Заказы: список + состав. Пока строится из /api/buyer/report (он работает);
/// после деплоя фикса /api/buyer/orders можно перейти на «родной» эндпоинт
/// (даст ещё PlacedAt, Comment, отмену/размещение).
/// Заказы: локальные черновики (статус «Не отправлен») + серверные заказы (из /api/buyer/report).
/// Черновик можно отправить (создать реальный заказ) или удалить.
/// </summary>
public partial class OrdersViewModel : ViewModelBase
{
@ -57,11 +63,38 @@ public partial class OrdersViewModel : ViewModelBase
try
{
Busy = true;
Orders.Clear();
// 1) Локальные черновики — вверху, статус «Не отправлен»
foreach (var d in DraftStore.All)
{
var items = new ObservableCollection<OrderItemRow>(
d.Items.Select(i => new OrderItemRow
{
Name = i.Name, Qty = i.Qty, Price = i.Price, Sum = i.LineSum,
SupplierPriceId = i.SupplierPriceId, Barcode = i.Barcode
}));
Orders.Add(new OrderSummary
{
Date = d.CreatedAt.ToString("dd.MM.yyyy"),
Number = d.Number,
Supplier = string.Join(", ", d.Items.Select(i => i.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Location = d.LocationAddress ?? "",
Status = "Не отправлен",
ItemsCount = d.ItemsCount,
Sum = d.Total,
Items = items,
IsDraft = true,
DraftId = d.Id,
LocationId = d.LocationId,
Comment = d.Comment
});
}
// 2) Серверные заказы
var to = DateTime.Today;
var from = to.AddMonths(-24);
var lines = await _api.GetBuyerReportAsync(from, to);
Orders.Clear();
foreach (var g in lines.GroupBy(l => l.OrderId)
.OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue))
{
@ -69,19 +102,14 @@ public partial class OrdersViewModel : ViewModelBase
var items = new ObservableCollection<OrderItemRow>(
g.Select(l => new OrderItemRow
{
Name = l.ItemName ?? "",
Code = l.ItemCode ?? "",
Qty = l.Qty,
Price = l.UnitPrice,
Sum = l.Sum
Name = l.ItemName ?? "", Code = l.ItemCode ?? "",
Qty = l.Qty, Price = l.UnitPrice, Sum = l.Sum
}));
Orders.Add(new OrderSummary
{
Date = f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "",
Number = f.GlobalSign ?? "",
Supplier = string.Join(", ", g.Select(x => x.Supplier)
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Supplier = string.Join(", ", g.Select(x => x.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct()),
Location = f.Location ?? "",
Status = f.Status ?? "",
ItemsCount = g.Count(),
@ -91,9 +119,46 @@ public partial class OrdersViewModel : ViewModelBase
}
SelectedOrder = Orders.FirstOrDefault();
if (Orders.Count == 0) Status = "Заказов за последние 2 года нет.";
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task SendDraftAsync()
{
if (SelectedOrder is not { IsDraft: true } d) return;
Status = null;
try
{
Busy = true;
var req = new BuyerOrderCreateRequest
{
LocationId = d.LocationId,
Comment = d.Comment,
Items = d.Items.Select(i => new BuyerOrderItemReq
{
SupplierPriceId = i.SupplierPriceId ?? "",
Qty = (double)i.Qty,
ItemName = i.Name,
Barcode = i.Barcode
}).ToList()
};
var resp = await _api.CreateOrderAsync(req);
if (d.DraftId is { } id) DraftStore.Remove(id);
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} отправлен ({resp.ItemsCount} поз., {resp.TotalAmount:N2}).";
await LoadAsync();
}
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private async Task DeleteDraftAsync()
{
if (SelectedOrder is not { IsDraft: true } d || d.DraftId is not { } id) return;
DraftStore.Remove(id);
await LoadAsync();
Status = "Черновик удалён.";
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -7,32 +8,57 @@ using Elfisa.Core;
namespace Elfisa.Avalonia.ViewModels;
/// <summary>Прайс-лист (сводный прайс) + добавление позиций в заказ (общая корзина).</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();
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 BuyerLocationDto? _selectedLocation;
[ObservableProperty] private string _comment = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string? _status;
[ObservableProperty] private int _total;
[ObservableProperty] private string _cartInfo = "";
[ObservableProperty] private decimal _cartTotal;
public PriceListViewModel()
{
_api.SetToken(Session.Current.Token);
_cart.Changed += UpdateCartInfo;
UpdateCartInfo();
if (Session.Current.IsAuthenticated) _ = SearchAsync();
_cart.Changed += Recalc;
Recalc();
if (Session.Current.IsAuthenticated)
{
_ = SearchAsync();
_ = LoadLocationsAsync();
}
}
private void UpdateCartInfo() =>
CartInfo = _cart.Count == 0 ? "Корзина пуста" : $"В заказе: {_cart.Count} поз. · {_cart.Total:N2}";
private void Recalc() => CartTotal = _cart.Total;
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 { }
}
[RelayCommand]
private async Task SearchAsync()
@ -45,8 +71,6 @@ public partial class PriceListViewModel : ViewModelBase
Items.Clear();
foreach (var it in resp.Summary ?? new()) Items.Add(it);
Total = resp.TotalDrugs;
Status = $"Показано {Items.Count} из {Total} позиций"
+ (string.IsNullOrWhiteSpace(Search) ? "" : $" по запросу «{Search.Trim()}»");
}
catch (Exception ex) { Status = ex.Message; }
finally { Busy = false; }
@ -56,8 +80,36 @@ public partial class PriceListViewModel : ViewModelBase
private void AddToOrder()
{
if (SelectedItem is null) { Status = "Выберите позицию в списке."; return; }
var q = Qty <= 0 ? 1 : Qty;
_cart.Add(SelectedItem, q);
Status = $"Добавлено в заказ: {SelectedItem.DrugName} × {q:N0}";
_cart.Add(SelectedItem, Qty <= 0 ? 1 : Qty);
Status = null;
}
[RelayCommand]
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
[RelayCommand]
private void SaveDraft()
{
if (_cart.Count == 0) { Status = "Заказ пуст — добавьте позиции."; return; }
var draft = new DraftOrder
{
LocationId = SelectedLocation?.BuyerLocationId,
LocationAddress = SelectedLocation?.Address,
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
Items = _cart.Items.Select(c => new DraftItem
{
SupplierPriceId = c.Source.SupplierPriceId ?? "",
Name = c.Name,
Supplier = c.Supplier,
Barcode = c.Source.Barcode,
Price = c.Price,
Qty = c.Qty
}).ToList()
};
var saved = DraftStore.Add(draft);
_cart.Clear();
Comment = "";
Status = $"Заказ сохранён как черновик {saved.Number} → раздел «Заказы» (не отправлен).";
}
}

View File

@ -43,14 +43,22 @@
<TextBlock Text="{Binding SelectedOrder.Location}" Classes="muted" TextWrapping="Wrap"/>
</StackPanel>
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="0,10,0,0"
BorderThickness="0,1,0,0" BorderBrush="{DynamicResource Border}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding SelectedOrder.Sum, StringFormat='{}{0:N2}'}"
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
</Grid>
</Border>
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
<Border Padding="0,10,0,0" BorderThickness="0,1,0,0" BorderBrush="{DynamicResource Border}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding SelectedOrder.Sum, StringFormat='{}{0:N2}'}"
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
</Grid>
</Border>
<!-- Действия для черновика (не отправленного заказа) -->
<StackPanel Orientation="Horizontal" Spacing="10" IsVisible="{Binding SelectedOrder.IsDraft, FallbackValue=False}">
<Button Classes="primary" Content="Отправить заказ" Command="{Binding SendDraftCommand}" IsEnabled="{Binding !Busy}"/>
<Button Content="Удалить" Command="{Binding DeleteDraftCommand}"
Background="Transparent" Foreground="{DynamicResource Muted}"
BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/>
</StackPanel>
</StackPanel>
<DataGrid ItemsSource="{Binding SelectedOrder.Items}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"

View File

@ -9,45 +9,91 @@
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock Text="Прайс-лист" Classes="h1"/>
<TextBlock Text="Каталог доступных позиций и цены" Classes="muted"/>
<TextBlock Text="Каталог позиций — собери заказ и сохрани" Classes="muted"/>
</StackPanel>
</Grid>
<Border DockPanel.Dock="Top" Classes="card" Margin="0,0,0,12" Padding="16">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBox Text="{Binding Search}" Width="440"
Watermark="поиск по наименованию (от 2 символов)…"/>
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
</Border>
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
Background="{DynamicResource AccentSoft}"
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" FontSize="12.5"/>
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" 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>
<Grid ColumnDefinitions="*,16,430">
<Border Classes="card" Padding="0" ClipToBounds="True">
<DataGrid ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
<DataGridTextColumn Header="Форма" Width="150" Binding="{Binding CureForm}"/>
<DataGridTextColumn Header="Производитель" Width="220" Binding="{Binding Manufacturer}"/>
<DataGridTextColumn Header="Поставщик" Width="130" Binding="{Binding SupplierName}"/>
<DataGridTextColumn Header="Цена" Width="110" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
</DataGrid.Columns>
</DataGrid>
</Border>
<!-- Каталог -->
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию (от 2 символов)…"/>
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
</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"/>
<Button Classes="primary" Content="Добавить в заказ ▸" Command="{Binding AddToOrderCommand}"/>
</StackPanel>
</Border>
<DataGrid ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem}" x:CompileBindings="False"
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
CanUserResizeColumns="True" CanUserSortColumns="False">
<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"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
<!-- Мой заказ -->
<Border Grid.Column="2" Classes="card" Padding="16">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Мой заказ" Classes="h2" Margin="0,0,0,10"/>
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
<StackPanel Spacing="5">
<TextBlock Text="Грузополучатель" Classes="muted"/>
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/>
</StackPanel>
<TextBox Text="{Binding Comment}" Watermark="комментарий (необязательно)"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{DynamicResource Text}"/>
<TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}" FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
</Grid>
<Button Classes="primary" Content="Сохранить заказ" HorizontalAlignment="Stretch" Command="{Binding SaveDraftCommand}"/>
<TextBlock Text="Черновик появится в «Заказы» со статусом «Не отправлен»." Classes="muted" TextWrapping="Wrap"/>
</StackPanel>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Cart}" x:CompileBindings="False">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CartItem">
<Border Padding="10" Margin="0,0,0,8" CornerRadius="9"
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="✕" Width="30" Height="30" Padding="0"
VerticalAlignment="Top" Background="Transparent" Foreground="{DynamicResource Muted}" Cursor="Hand"
Command="{Binding $parent[ItemsControl].DataContext.RemoveFromCartCommand}"
CommandParameter="{Binding}"/>
<StackPanel Spacing="6">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}" TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Supplier}" Classes="muted"/>
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000" Increment="1" FormatString="0" Width="120"/>
<TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}" Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
<TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}" FontWeight="SemiBold" Foreground="{DynamicResource Text}" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</UserControl>

View File

@ -0,0 +1,77 @@
using System.Text.Json;
namespace Elfisa.Core;
public sealed class DraftItem
{
public string SupplierPriceId { get; set; } = "";
public string Name { get; set; } = "";
public string Supplier { get; set; } = "";
public string? Barcode { get; set; }
public decimal Price { get; set; }
public decimal Qty { get; set; } = 1;
public decimal LineSum => Price * Qty;
}
public sealed class DraftOrder
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Number { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.Now;
public string? LocationId { get; set; }
public string? LocationAddress { get; set; }
public string? Comment { get; set; }
public List<DraftItem> Items { get; set; } = new();
public decimal Total => Items.Sum(i => i.LineSum);
public int ItemsCount => Items.Count;
}
/// <summary>Локальные черновики заказов (%APPDATA%\ElfisaAvalonia\drafts.json).</summary>
public static class DraftStore
{
private sealed class Box { public int Seq { get; set; } public List<DraftOrder> Drafts { get; set; } = new(); }
private static readonly string Dir =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ElfisaAvalonia");
private static readonly string File = Path.Combine(Dir, "drafts.json");
private static Box _box = Load();
public static IReadOnlyList<DraftOrder> All => _box.Drafts;
private static Box Load()
{
try
{
if (System.IO.File.Exists(File))
return JsonSerializer.Deserialize<Box>(System.IO.File.ReadAllText(File)) ?? new Box();
}
catch { }
return new Box();
}
private static void Save()
{
try
{
Directory.CreateDirectory(Dir);
System.IO.File.WriteAllText(File, JsonSerializer.Serialize(_box, new JsonSerializerOptions { WriteIndented = true }));
}
catch { }
}
public static DraftOrder Add(DraftOrder draft)
{
_box.Seq++;
draft.Number = $"ЧРН-{_box.Seq:0000}";
_box.Drafts.Insert(0, draft);
Save();
return draft;
}
public static void Remove(string id)
{
_box.Drafts.RemoveAll(d => d.Id == id);
Save();
}
}