diff --git a/Elfisa.Cross.slnx b/Elfisa.Cross.slnx
new file mode 100644
index 0000000..4a27b69
--- /dev/null
+++ b/Elfisa.Cross.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/App.axaml b/src/Elfisa.Avalonia/App.axaml
new file mode 100644
index 0000000..dd9e0f1
--- /dev/null
+++ b/src/Elfisa.Avalonia/App.axaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #0F766E
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/App.axaml.cs b/src/Elfisa.Avalonia/App.axaml.cs
new file mode 100644
index 0000000..cba5ce7
--- /dev/null
+++ b/src/Elfisa.Avalonia/App.axaml.cs
@@ -0,0 +1,31 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core;
+using Avalonia.Data.Core.Plugins;
+using System.Linq;
+using Avalonia.Markup.Xaml;
+using Elfisa.Avalonia.ViewModels;
+using Elfisa.Avalonia.Views;
+
+namespace Elfisa.Avalonia;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = new RootViewModel(),
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
\ No newline at end of file
diff --git a/src/Elfisa.Avalonia/Assets/avalonia-logo.ico b/src/Elfisa.Avalonia/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..f7da8bb
Binary files /dev/null and b/src/Elfisa.Avalonia/Assets/avalonia-logo.ico differ
diff --git a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
new file mode 100644
index 0000000..ed75769
--- /dev/null
+++ b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
@@ -0,0 +1,31 @@
+
+
+ WinExe
+ net8.0
+ enable
+ app.manifest
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/Program.cs b/src/Elfisa.Avalonia/Program.cs
new file mode 100644
index 0000000..ea1dc00
--- /dev/null
+++ b/src/Elfisa.Avalonia/Program.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using System;
+
+namespace Elfisa.Avalonia;
+
+sealed class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+#if DEBUG
+ .WithDeveloperTools()
+#endif
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/Elfisa.Avalonia/README.md b/src/Elfisa.Avalonia/README.md
new file mode 100644
index 0000000..d716404
--- /dev/null
+++ b/src/Elfisa.Avalonia/README.md
@@ -0,0 +1,65 @@
+# ЭльФиСА — кросс-платформенный клиент (Avalonia)
+
+Порт десктопного клиента с WinForms на **Avalonia UI** (.NET 8) —
+работает на **Windows, macOS и Linux** из одного кода. Начат как замена
+WinForms-версии (которая только под Windows).
+
+## Что уже готово (фундамент + эталонный экран)
+- **Elfisa.Core** — переносимая бизнес-логика (без UI): `ApiClient`
+ (логин, отчёт покупателя, ИИ-отчёт), DTO, `AppConfig`, `Session`.
+- **Дизайн-система** в фирменной бирюзе (#0F766E): светлая/тёмная тема,
+ стили кнопок, полей, карточек, таблицы, сайдбара (`Styles/Elfisa.axaml`).
+- **Экран входа** (`LoginView`) — реальная авторизация через `/auth/login`.
+- **Шелл** (`ShellView`) — сайдбар-навигация + карточка пользователя + выход.
+- **Отчёты** (`ReportsView`) — ПОЛНОСТЬЮ рабочий эталонный раздел:
+ период, 4 типа отчёта (агрегация как в WinForms), авто-загрузка,
+ свободный **ИИ-запрос → Word/Excel** (через `/ai`), таблица с динамическими
+ колонками. Данные скоупятся по JWT — только заказы этого покупателя.
+- Заглушки остальных разделов (Прайс-лист, Заказы, Накладные, Отправить,
+ Отказы, Справочник).
+
+## Архитектура
+MVVM (CommunityToolkit.Mvvm) + ViewLocator (VM → View по имени).
+```
+Program → App → MainWindow(RootViewModel)
+ RootViewModel: LoginViewModel ⇄ ShellViewModel
+ ShellViewModel.CurrentPage: ReportsViewModel | PlaceholderPageViewModel | …
+```
+Вся сетевая логика — в `Elfisa.Core` (переиспользуется и десктопом, и
+будущим мобильным/веб-клиентом Avalonia).
+
+## Запуск
+```bash
+dotnet run --project src/Elfisa.Avalonia
+```
+Dev-автологин (удобно при разработке — минуя экран входа):
+```bash
+# Windows PowerShell
+$env:ELF_DEV_USER="user@example.com"; $env:ELF_DEV_PASS="***"; dotnet run --project src/Elfisa.Avalonia
+```
+Переопределение адресов (по умолчанию https://24pharmdata.ru и .../ai):
+`ELF_API_BASE_URL`, `ELF_AI_BASE_URL`.
+
+Сборка под конкретную ОС:
+```bash
+dotnet publish src/Elfisa.Avalonia -c Release -r osx-arm64 --self-contained # Mac (M-серия)
+dotnet publish src/Elfisa.Avalonia -c Release -r win-x64 --self-contained # Windows
+```
+
+## Дорожная карта портирования
+Логика этих экранов частично уже есть в WinForms `ApiClient` — переносится
+в `Elfisa.Core` метод за методом, UI пишется по образцу `ReportsView`.
+
+| Раздел | Что нужно | Сложность |
+|---|---|---|
+| Прайс-лист | поиск/каталог, наценки, добавление в заказ, большой грид | высокая |
+| Заказы | список + карточка заказа + позиции | средняя |
+| Накладные | список приходов | средняя |
+| Отправить | сборка заявки + выгрузка DBF (портировать `orderexport`) | средняя |
+| Отказы | список отказных позиций | низкая |
+| Справочник | контрагенты, грузополучатели, настройки | средняя |
+| Автообновление | аналог AutoUpdater под каждую ОС | средняя |
+
+**Порядок рекомендую:** Заказы → Прайс-лист → Отправить → остальное.
+Бизнес-логика каждого — обычный C#, переносится как есть; переписывается
+только UI (WinForms → Avalonia XAML), что и есть основная работа.
diff --git a/src/Elfisa.Avalonia/Styles/Elfisa.axaml b/src/Elfisa.Avalonia/Styles/Elfisa.axaml
new file mode 100644
index 0000000..447711b
--- /dev/null
+++ b/src/Elfisa.Avalonia/Styles/Elfisa.axaml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/ViewLocator.cs b/src/Elfisa.Avalonia/ViewLocator.cs
new file mode 100644
index 0000000..e5eca54
--- /dev/null
+++ b/src/Elfisa.Avalonia/ViewLocator.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Elfisa.Avalonia.ViewModels;
+
+namespace Elfisa.Avalonia;
+
+///
+/// Given a view model, returns the corresponding view if possible.
+///
+[RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
diff --git a/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs b/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs
new file mode 100644
index 0000000..b7520be
--- /dev/null
+++ b/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Elfisa.Core;
+
+namespace Elfisa.Avalonia.ViewModels;
+
+public partial class LoginViewModel : ViewModelBase
+{
+ private readonly Action _onSuccess;
+ private readonly ApiClient _api = new();
+
+ [ObservableProperty] private string _username = "";
+ [ObservableProperty] private string _password = "";
+ [ObservableProperty] private string? _error;
+ [ObservableProperty] private bool _busy;
+
+ public LoginViewModel(Action onSuccess) => _onSuccess = onSuccess;
+
+ [RelayCommand]
+ private async Task LoginAsync()
+ {
+ Error = null;
+ if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
+ {
+ Error = "Введите логин и пароль.";
+ return;
+ }
+
+ try
+ {
+ Busy = true;
+ var resp = await _api.LoginAsync(Username.Trim(), Password);
+ Session.Current.Token = resp.Token;
+ Session.Current.Role = resp.Role;
+ Session.Current.Username = Username.Trim();
+ _onSuccess();
+ }
+ catch (ApiException ex) { Error = ex.Message; }
+ catch (Exception ex) { Error = "Не удалось войти: " + ex.Message; }
+ finally { Busy = false; }
+ }
+}
diff --git a/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs b/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs
new file mode 100644
index 0000000..86a9ca1
--- /dev/null
+++ b/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs
@@ -0,0 +1,15 @@
+namespace Elfisa.Avalonia.ViewModels;
+
+/// Заглушка ещё не портированного раздела.
+public partial class PlaceholderPageViewModel : ViewModelBase
+{
+ public string Title { get; }
+ public string Description { get; }
+ public string Note { get; } = "Раздел портируется на Avalonia. Пока доступен в WinForms-клиенте.";
+
+ public PlaceholderPageViewModel(string title, string description)
+ {
+ Title = title;
+ Description = description;
+ }
+}
diff --git a/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs b/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs
new file mode 100644
index 0000000..b438037
--- /dev/null
+++ b/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Elfisa.Core;
+
+namespace Elfisa.Avalonia.ViewModels;
+
+public sealed class ReportColumn
+{
+ public string Header { get; init; } = "";
+ public bool Numeric { get; init; }
+ public double Weight { get; init; } = 100; // ширина в пикселях (для нетянущихся)
+ public bool Stretch { get; init; } // тянуть на всё свободное место (star)
+}
+
+/// Строка отчёта: ячейки как строки, доступ по индексу для DataGrid.
+public sealed class ReportRow
+{
+ private readonly string[] _cells;
+ public ReportRow(string[] cells) => _cells = cells;
+ public string this[int i] => i >= 0 && i < _cells.Length ? _cells[i] : "";
+}
+
+public partial class ReportsViewModel : ViewModelBase
+{
+ private readonly ApiClient _api = new();
+ private List _lines = new();
+
+ /// Сигнал View пересобрать колонки DataGrid.
+ public event Action? ColumnsChanged;
+
+ public ObservableCollection Columns { get; } = new();
+ public ObservableCollection Rows { get; } = new();
+
+ [ObservableProperty] private DateTimeOffset _dateFrom = DateTimeOffset.Now.AddMonths(-1);
+ [ObservableProperty] private DateTimeOffset _dateTo = DateTimeOffset.Now;
+
+ public string[] ReportTypes { get; } = { "Мои заказы", "По поставщикам", "По товарам", "По аптекам" };
+ [ObservableProperty] private int _selectedReportIndex;
+
+ public string[] AiFormats { get; } = { "Word", "Excel" };
+ [ObservableProperty] private int _selectedAiFormat;
+ [ObservableProperty] private string _aiPrompt = "";
+
+ [ObservableProperty] private string _totals = "";
+ [ObservableProperty] private string? _status;
+ [ObservableProperty] private bool _busy;
+ [ObservableProperty] private bool _aiBusy;
+
+ public ReportsViewModel()
+ {
+ _api.SetToken(Session.Current.Token);
+ if (Session.Current.IsAuthenticated)
+ _ = BuildAsync(); // авто-загрузка за последний месяц при открытии
+ }
+
+ partial void OnSelectedReportIndexChanged(int value) => Render();
+
+ [RelayCommand]
+ private async Task BuildAsync()
+ {
+ Status = null;
+ try
+ {
+ Busy = true;
+ _lines = await _api.GetBuyerReportAsync(DateFrom.DateTime.Date, DateTo.DateTime.Date);
+ Render();
+ if (_lines.Count == 0) Status = "За выбранный период заказов нет.";
+ }
+ catch (Exception ex) { Status = ex.Message; }
+ finally { Busy = false; }
+ }
+
+ [RelayCommand]
+ private async Task GenerateAiAsync()
+ {
+ Status = null;
+ if (string.IsNullOrWhiteSpace(AiPrompt)) { Status = "Введите запрос для ИИ."; return; }
+ try
+ {
+ AiBusy = true;
+ var fmt = SelectedAiFormat == 1 ? "xlsx" : "docx";
+ var res = await _api.GenerateAiReportAsync(AiPrompt.Trim(), fmt);
+ var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
+ var safe = string.Join("_", res.FileName.Split(Path.GetInvalidFileNameChars()));
+ var path = Path.Combine(dir, safe);
+ if (File.Exists(path))
+ {
+ var name = Path.GetFileNameWithoutExtension(safe);
+ var ext = Path.GetExtension(safe);
+ path = Path.Combine(dir, $"{name}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}");
+ }
+ File.WriteAllBytes(path, res.Content);
+ Status = "Отчёт сохранён: " + path;
+ OpenFile(path);
+ }
+ catch (Exception ex) { Status = ex.Message; }
+ finally { AiBusy = false; }
+ }
+
+ private void Render()
+ {
+ Columns.Clear();
+ Rows.Clear();
+
+ var (cols, rows) = Aggregate(_lines, SelectedReportIndex);
+ foreach (var c in cols) Columns.Add(c);
+ foreach (var r in rows) Rows.Add(new ReportRow(r));
+
+ var orders = _lines.Select(l => l.OrderId).Distinct().Count();
+ var sum = _lines.Sum(l => l.Sum);
+ Totals = $"Заказов: {orders} Позиций: {_lines.Count} Сумма: {sum:N2}";
+ ColumnsChanged?.Invoke();
+ }
+
+ private static (List cols, List rows) Aggregate(List lines, int type)
+ {
+ string N2(decimal d) => d.ToString("N2");
+ string N3(decimal d) => d.ToString("N3");
+ string D(DateTime? dt) => dt?.ToLocalTime().ToString("dd.MM.yyyy") ?? "";
+
+ switch (type)
+ {
+ case 1: // По поставщикам
+ {
+ var cols = new List
+ {
+ new() { Header = "Поставщик", Stretch = true },
+ new() { Header = "Заказов", Numeric = true, Weight = 90 },
+ new() { Header = "Позиций", Numeric = true, Weight = 90 },
+ new() { Header = "Сумма", Numeric = true, Weight = 120 },
+ };
+ var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Supplier) ? "(не указан)" : l.Supplier!)
+ .OrderByDescending(g => g.Sum(x => x.Sum))
+ .Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
+ g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
+ return (cols, rows);
+ }
+ case 2: // По товарам
+ {
+ var cols = new List
+ {
+ new() { Header = "Товар", Stretch = true },
+ new() { Header = "Код", Weight = 100 },
+ new() { Header = "Кол-во", Numeric = true, Weight = 100 },
+ new() { Header = "Сумма", Numeric = true, Weight = 120 },
+ };
+ var rows = lines.GroupBy(l => ((l.ItemName ?? "").Trim() + "|" + (l.ItemCode ?? "")))
+ .OrderByDescending(g => g.Sum(x => x.Sum))
+ .Select(g => { var f = g.First(); return new[] { f.ItemName ?? "", f.ItemCode ?? "",
+ N3(g.Sum(x => x.Qty)), N2(g.Sum(x => x.Sum)) }; }).ToList();
+ return (cols, rows);
+ }
+ case 3: // По аптекам
+ {
+ var cols = new List
+ {
+ new() { Header = "Аптека", Stretch = true },
+ new() { Header = "Заказов", Numeric = true, Weight = 90 },
+ new() { Header = "Позиций", Numeric = true, Weight = 90 },
+ new() { Header = "Сумма", Numeric = true, Weight = 120 },
+ };
+ var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Location) ? "(не указана)" : l.Location!)
+ .OrderByDescending(g => g.Sum(x => x.Sum))
+ .Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
+ g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
+ return (cols, rows);
+ }
+ default: // Мои заказы
+ {
+ var cols = new List
+ {
+ new() { Header = "Дата", Weight = 105 },
+ new() { Header = "Номер", Weight = 138 },
+ new() { Header = "Поставщик", Weight = 150 },
+ new() { Header = "Аптека", Stretch = true },
+ new() { Header = "Позиций", Numeric = true, Weight = 95 },
+ new() { Header = "Сумма", Numeric = true, Weight = 125 },
+ new() { Header = "Статус", Weight = 100 },
+ };
+ var rows = lines.GroupBy(l => l.OrderId)
+ .OrderByDescending(g => g.First().OrderDate ?? DateTime.MinValue)
+ .Select(g =>
+ {
+ var f = g.First();
+ var sup = string.Join(", ", g.Select(x => x.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct());
+ return new[] { D(f.OrderDate), f.GlobalSign ?? "", sup, f.Location ?? "",
+ g.Count().ToString(), N2(g.Sum(x => x.Sum)), f.Status ?? "" };
+ }).ToList();
+ return (cols, rows);
+ }
+ }
+ }
+
+ private static void OpenFile(string path)
+ {
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ Process.Start("open", $"\"{path}\"");
+ else
+ Process.Start("xdg-open", $"\"{path}\"");
+ }
+ catch { /* открыть не удалось — путь показан в статусе */ }
+ }
+}
diff --git a/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs
new file mode 100644
index 0000000..6016726
--- /dev/null
+++ b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs
@@ -0,0 +1,48 @@
+using System;
+using Elfisa.Core;
+
+namespace Elfisa.Avalonia.ViewModels;
+
+/// Корневой VM: переключает экран входа и основной шелл.
+public partial class RootViewModel : ViewModelBase
+{
+ private ViewModelBase _current = null!;
+ public ViewModelBase Current
+ {
+ get => _current;
+ private set => SetProperty(ref _current, value);
+ }
+
+ public RootViewModel()
+ {
+ Current = new LoginViewModel(OnLoggedIn);
+ TryDevAutoLogin();
+ }
+
+ // Dev-режим: если заданы ELF_DEV_USER/ELF_DEV_PASS — входим автоматически.
+ // Обычных пользователей не затрагивает (переменных нет).
+ private async void TryDevAutoLogin()
+ {
+ var u = Environment.GetEnvironmentVariable("ELF_DEV_USER");
+ var p = Environment.GetEnvironmentVariable("ELF_DEV_PASS");
+ if (string.IsNullOrWhiteSpace(u) || string.IsNullOrWhiteSpace(p)) return;
+ try
+ {
+ var api = new ApiClient();
+ var resp = await api.LoginAsync(u, p);
+ Session.Current.Token = resp.Token;
+ Session.Current.Role = resp.Role;
+ Session.Current.Username = u;
+ OnLoggedIn();
+ }
+ catch { /* остаёмся на экране входа */ }
+ }
+
+ private void OnLoggedIn() => Current = new ShellViewModel(Logout);
+
+ private void Logout()
+ {
+ Session.Current.Clear();
+ Current = new LoginViewModel(OnLoggedIn);
+ }
+}
diff --git a/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs b/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs
new file mode 100644
index 0000000..f16eae6
--- /dev/null
+++ b/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Elfisa.Core;
+
+namespace Elfisa.Avalonia.ViewModels;
+
+public class NavItem
+{
+ public string Title { get; }
+ public string Icon { get; }
+ public Func Factory { get; }
+ public NavItem(string title, string icon, Func factory)
+ {
+ Title = title; Icon = icon; Factory = factory;
+ }
+}
+
+public partial class ShellViewModel : ViewModelBase
+{
+ private readonly Action _logout;
+
+ public ObservableCollection NavItems { get; }
+
+ [ObservableProperty] private NavItem? _selectedNav;
+ [ObservableProperty] private ViewModelBase? _currentPage;
+
+ public string UserName => Session.Current.Username ?? "";
+ public string RoleLabel => Session.Current.Role switch
+ {
+ "buyer" => "Покупатель",
+ "supplier" => "Поставщик",
+ "manager" => "Менеджер",
+ "admin" => "Администратор",
+ _ => Session.Current.Role ?? ""
+ };
+
+ public ShellViewModel(Action logout)
+ {
+ _logout = logout;
+ NavItems = new ObservableCollection
+ {
+ new("Прайс-лист", "P", () => new PlaceholderPageViewModel("Прайс-лист", "Каталог, поиск и добавление позиций в заказ.")),
+ new("Заказы", "З", () => new PlaceholderPageViewModel("Заказы", "История заказов и их статусы.")),
+ new("Накладные", "Н", () => new PlaceholderPageViewModel("Накладные", "Приход товара по заказам.")),
+ new("Отчёты", "О", () => new ReportsViewModel()),
+ new("Отправить", "→", () => new PlaceholderPageViewModel("Отправить", "Формирование и выгрузка заявок поставщику (DBF).")),
+ new("Отказы", "!", () => new PlaceholderPageViewModel("Отказы", "Отказные позиции по заказам.")),
+ new("Справочник", "С", () => new PlaceholderPageViewModel("Справочник", "Контрагенты, грузополучатели, настройки.")),
+ };
+ SelectedNav = NavItems.First(n => n.Title == "Отчёты");
+ }
+
+ partial void OnSelectedNavChanged(NavItem? value) => CurrentPage = value?.Factory();
+
+ [RelayCommand] private void Logout() => _logout();
+}
diff --git a/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs b/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..da8f4b2
--- /dev/null
+++ b/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Elfisa.Avalonia.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/src/Elfisa.Avalonia/Views/LoginView.axaml b/src/Elfisa.Avalonia/Views/LoginView.axaml
new file mode 100644
index 0000000..6c89397
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/LoginView.axaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/Views/LoginView.axaml.cs b/src/Elfisa.Avalonia/Views/LoginView.axaml.cs
new file mode 100644
index 0000000..6a82373
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/LoginView.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Elfisa.Avalonia.Views;
+
+public partial class LoginView : UserControl
+{
+ public LoginView() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/Elfisa.Avalonia/Views/MainWindow.axaml b/src/Elfisa.Avalonia/Views/MainWindow.axaml
new file mode 100644
index 0000000..8f476b8
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/MainWindow.axaml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/Views/MainWindow.axaml.cs b/src/Elfisa.Avalonia/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..8635b98
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/MainWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Elfisa.Avalonia.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml b/src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml
new file mode 100644
index 0000000..98f6f34
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml.cs b/src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml.cs
new file mode 100644
index 0000000..be7a332
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Elfisa.Avalonia.Views;
+
+public partial class PlaceholderPageView : UserControl
+{
+ public PlaceholderPageView() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/Elfisa.Avalonia/Views/ReportsView.axaml b/src/Elfisa.Avalonia/Views/ReportsView.axaml
new file mode 100644
index 0000000..08f4200
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/ReportsView.axaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/Views/ReportsView.axaml.cs b/src/Elfisa.Avalonia/Views/ReportsView.axaml.cs
new file mode 100644
index 0000000..635b4e3
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/ReportsView.axaml.cs
@@ -0,0 +1,60 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Elfisa.Avalonia.ViewModels;
+
+namespace Elfisa.Avalonia.Views;
+
+public partial class ReportsView : UserControl
+{
+ private ReportsViewModel? _vm;
+
+ public ReportsView()
+ {
+ InitializeComponent();
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs e)
+ {
+ if (_vm != null) _vm.ColumnsChanged -= RebuildColumns;
+ _vm = DataContext as ReportsViewModel;
+ if (_vm != null)
+ {
+ _vm.ColumnsChanged += RebuildColumns;
+ RebuildColumns();
+ }
+ }
+
+ // DataGrid не умеет динамические колонки через биндинг — строим их из VM в коде.
+ private void RebuildColumns()
+ {
+ Grid.Columns.Clear();
+ if (_vm == null) return;
+
+ for (int i = 0; i < _vm.Columns.Count; i++)
+ {
+ int idx = i;
+ var c = _vm.Columns[i];
+ Grid.Columns.Add(new DataGridTemplateColumn
+ {
+ Header = c.Header,
+ // одна тянущаяся колонка (star) заполняет свободное место, остальные фикс. пиксель
+ Width = c.Stretch
+ ? new DataGridLength(1, DataGridLengthUnitType.Star)
+ : new DataGridLength(c.Weight, DataGridLengthUnitType.Pixel),
+ MinWidth = 56,
+ CellTemplate = new FuncDataTemplate((row, _) => new TextBlock
+ {
+ Text = row?[idx] ?? "",
+ VerticalAlignment = VerticalAlignment.Center,
+ TextAlignment = c.Numeric ? TextAlignment.Right : TextAlignment.Left,
+ TextTrimming = TextTrimming.CharacterEllipsis
+ })
+ });
+ }
+ }
+}
diff --git a/src/Elfisa.Avalonia/Views/ShellView.axaml b/src/Elfisa.Avalonia/Views/ShellView.axaml
new file mode 100644
index 0000000..e2db067
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/ShellView.axaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Avalonia/Views/ShellView.axaml.cs b/src/Elfisa.Avalonia/Views/ShellView.axaml.cs
new file mode 100644
index 0000000..9797272
--- /dev/null
+++ b/src/Elfisa.Avalonia/Views/ShellView.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Elfisa.Avalonia.Views;
+
+public partial class ShellView : UserControl
+{
+ public ShellView() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/Elfisa.Avalonia/app.manifest b/src/Elfisa.Avalonia/app.manifest
new file mode 100644
index 0000000..c62cb33
--- /dev/null
+++ b/src/Elfisa.Avalonia/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Elfisa.Core/ApiClient.cs b/src/Elfisa.Core/ApiClient.cs
new file mode 100644
index 0000000..e0c6299
--- /dev/null
+++ b/src/Elfisa.Core/ApiClient.cs
@@ -0,0 +1,130 @@
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+
+namespace Elfisa.Core;
+
+///
+/// Клиент платформы. Портирован из WinForms-версии, оставлены методы,
+/// нужные новому Avalonia-клиенту: логин, отчёт покупателя, ИИ-отчёт.
+/// Остальные эндпоинты добавляются по мере портирования экранов.
+///
+public sealed class ApiClient
+{
+ private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = null };
+
+ private readonly HttpClient _http;
+ private readonly string _baseUrl;
+ private string? _token;
+
+ public ApiClient(string? baseUrl = null)
+ {
+ _baseUrl = (baseUrl ?? AppConfig.ApiBaseUrl).TrimEnd('/');
+ _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
+ }
+
+ public string BaseUrl => _baseUrl;
+ public string? Token => _token;
+ public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
+
+ public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token;
+
+ private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
+
+ /// Логин. Возвращает (token, role) либо бросает с понятным сообщением.
+ public async Task LoginAsync(string username, string password)
+ {
+ var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
+ using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ };
+ HttpResponseMessage resp;
+ try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
+ catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
+
+ var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if (resp.StatusCode == HttpStatusCode.Unauthorized)
+ throw new ApiException("Неверный логин или пароль.");
+ if (resp.StatusCode == (HttpStatusCode)429)
+ throw new ApiException("Слишком много запросов. Подождите немного и повторите.");
+ if (!resp.IsSuccessStatusCode)
+ throw new ApiException(TryError(text) ?? $"Ошибка авторизации ({(int)resp.StatusCode}).");
+
+ var login = JsonSerializer.Deserialize(text, Json);
+ if (login is null || string.IsNullOrWhiteSpace(login.Token))
+ throw new ApiException("Сервер вернул пустой токен.");
+ _token = login.Token;
+ return login;
+ }
+
+ /// Отчёт покупателя за период (скоуп по JWT на сервере — только свои заказы).
+ public async Task> GetBuyerReportAsync(DateTime from, DateTime to)
+ {
+ EnsureAuth();
+ var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
+ var text = await AuthorizedGetAsync(path, "формирование отчёта").ConfigureAwait(false);
+ var resp = JsonSerializer.Deserialize(text, Json);
+ return resp?.Lines ?? new List();
+ }
+
+ /// Свободный запрос к ИИ -> готовый Word/Excel (сервис на Mac mini через /ai).
+ public async Task GenerateAiReportAsync(string prompt, string format = "docx")
+ {
+ EnsureAuth();
+ if (string.IsNullOrWhiteSpace(prompt)) throw new ApiException("Пустой запрос для ИИ.");
+ var aiUrl = new Uri(new Uri(AppConfig.AiBaseUrl.TrimEnd('/') + "/"), "generate").ToString();
+ var body = JsonSerializer.Serialize(new AiReportRequest { Prompt = prompt, Token = _token!, Format = format }, Json);
+ using var req = new HttpRequestMessage(HttpMethod.Post, aiUrl)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ };
+ HttpResponseMessage resp;
+ try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
+ catch (Exception ex) { throw new ApiException($"ИИ-сервис недоступен: {ex.Message}", ex); }
+
+ if (!resp.IsSuccessStatusCode)
+ {
+ var err = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ApiException($"ИИ-сервис вернул {(int)resp.StatusCode}: {TryError(err) ?? err}");
+ }
+ var bytes = await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
+ var name = resp.Content.Headers.ContentDisposition?.FileNameStar
+ ?? resp.Content.Headers.ContentDisposition?.FileName
+ ?? (format == "xlsx" ? "Отчёт.xlsx" : "Отчёт.docx");
+ return new AiReportResult { Content = bytes, FileName = name.Trim('"') };
+ }
+
+ private async Task AuthorizedGetAsync(string path, string op)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Get, Url(path));
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+ HttpResponseMessage resp;
+ try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
+ catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
+
+ var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
+ if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка сервера ({(int)resp.StatusCode}) при {op}.");
+ return text;
+ }
+
+ private void EnsureAuth()
+ {
+ if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");
+ }
+
+ private static string? TryError(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body)) return null;
+ try { return JsonSerializer.Deserialize(body, Json)?.Error; }
+ catch { return null; }
+ }
+}
+
+public sealed class ApiException : Exception
+{
+ public ApiException(string message, Exception? inner = null) : base(message, inner) { }
+}
diff --git a/src/Elfisa.Core/AppConfig.cs b/src/Elfisa.Core/AppConfig.cs
new file mode 100644
index 0000000..4c2c73a
--- /dev/null
+++ b/src/Elfisa.Core/AppConfig.cs
@@ -0,0 +1,33 @@
+namespace Elfisa.Core;
+
+///
+/// Базовые адреса платформы. Совпадают с десктопом WinForms:
+/// API — https://24pharmdata.ru, ИИ-отчёты — https://24pharmdata.ru/ai.
+/// Можно переопределить переменными окружения.
+///
+public static class AppConfig
+{
+ public const string DefaultApiBaseUrl = "https://24pharmdata.ru";
+ public const string DefaultAiBaseUrl = "https://24pharmdata.ru/ai";
+
+ private const string ApiEnv = "ELF_API_BASE_URL";
+ private const string AiEnv = "ELF_AI_BASE_URL";
+
+ public static string ApiBaseUrl
+ {
+ get
+ {
+ var env = Environment.GetEnvironmentVariable(ApiEnv);
+ return string.IsNullOrWhiteSpace(env) ? DefaultApiBaseUrl : env.Trim().TrimEnd('/');
+ }
+ }
+
+ public static string AiBaseUrl
+ {
+ get
+ {
+ var env = Environment.GetEnvironmentVariable(AiEnv);
+ return string.IsNullOrWhiteSpace(env) ? DefaultAiBaseUrl : env.Trim().TrimEnd('/');
+ }
+ }
+}
diff --git a/src/Elfisa.Core/Dtos.cs b/src/Elfisa.Core/Dtos.cs
new file mode 100644
index 0000000..b4383c9
--- /dev/null
+++ b/src/Elfisa.Core/Dtos.cs
@@ -0,0 +1,54 @@
+using System.Text.Json.Serialization;
+
+namespace Elfisa.Core;
+
+public sealed class LoginRequest
+{
+ [JsonPropertyName("username")] public string Username { get; set; } = "";
+ [JsonPropertyName("password")] public string Password { get; set; } = "";
+}
+
+public sealed class LoginResponse
+{
+ [JsonPropertyName("token")] public string? Token { get; set; }
+ [JsonPropertyName("role")] public string? Role { get; set; }
+ [JsonPropertyName("redirect_url")] public string? RedirectUrl { get; set; }
+}
+
+public sealed class BuyerReportResponse
+{
+ [JsonPropertyName("lines")] public List? Lines { get; set; }
+}
+
+public sealed class BuyerReportLineDto
+{
+ [JsonPropertyName("order_id")] public string? OrderId { get; set; }
+ [JsonPropertyName("global_sign")] public string? GlobalSign { get; set; }
+ [JsonPropertyName("order_date")] public DateTime? OrderDate { get; set; }
+ [JsonPropertyName("status")] public string? Status { get; set; }
+ [JsonPropertyName("supplier")] public string? Supplier { get; set; }
+ [JsonPropertyName("location")] public string? Location { get; set; }
+ [JsonPropertyName("item_name")] public string? ItemName { get; set; }
+ [JsonPropertyName("item_code")] public string? ItemCode { get; set; }
+ [JsonPropertyName("qty")] public decimal Qty { get; set; }
+ [JsonPropertyName("unit_price")] public decimal UnitPrice { get; set; }
+ [JsonPropertyName("sum")] public decimal Sum { get; set; }
+}
+
+public sealed class AiReportRequest
+{
+ [JsonPropertyName("prompt")] public string Prompt { get; set; } = "";
+ [JsonPropertyName("token")] public string Token { get; set; } = "";
+ [JsonPropertyName("format")] public string Format { get; set; } = "docx";
+}
+
+public sealed class AiReportResult
+{
+ public byte[] Content { get; set; } = Array.Empty();
+ public string FileName { get; set; } = "report.docx";
+}
+
+public sealed class ErrorResponse
+{
+ [JsonPropertyName("error")] public string? Error { get; set; }
+}
diff --git a/src/Elfisa.Core/Elfisa.Core.csproj b/src/Elfisa.Core/Elfisa.Core.csproj
new file mode 100644
index 0000000..fa71b7a
--- /dev/null
+++ b/src/Elfisa.Core/Elfisa.Core.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/src/Elfisa.Core/Session.cs b/src/Elfisa.Core/Session.cs
new file mode 100644
index 0000000..072b419
--- /dev/null
+++ b/src/Elfisa.Core/Session.cs
@@ -0,0 +1,20 @@
+namespace Elfisa.Core;
+
+/// Текущая сессия пользователя (токен, роль, логин).
+public sealed class Session
+{
+ public static Session Current { get; } = new();
+
+ public string? Token { get; set; }
+ public string? Role { get; set; }
+ public string? Username { get; set; }
+
+ public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token);
+
+ public void Clear()
+ {
+ Token = null;
+ Role = null;
+ Username = null;
+ }
+}