Merge pull request '������� �� Avalonia 2.0 (�����-���������, ����������, �������� ��������)' (#1) from feature/avalonia-ui into master
5
.gitignore
vendored
@ -25,3 +25,8 @@ packages/
|
|||||||
installer/output/
|
installer/output/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
|
||||||
|
# Кросс-платформенные публикации и артефакты сборки
|
||||||
|
publish/
|
||||||
|
build/
|
||||||
|
installer/output/
|
||||||
|
|||||||
6
Elfisa.Cross.slnx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Elfisa.Avalonia/Elfisa.Avalonia.csproj" />
|
||||||
|
<Project Path="src/Elfisa.Core/Elfisa.Core.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@ -29,6 +29,9 @@
|
|||||||
<setting name="IntDefaultPercentMarkup" serializeAs="String">
|
<setting name="IntDefaultPercentMarkup" serializeAs="String">
|
||||||
<value>0</value>
|
<value>0</value>
|
||||||
</setting>
|
</setting>
|
||||||
|
<setting name="ClientMarkupPercent" serializeAs="String">
|
||||||
|
<value>0</value>
|
||||||
|
</setting>
|
||||||
<setting name="stringLogin" serializeAs="String">
|
<setting name="stringLogin" serializeAs="String">
|
||||||
<value />
|
<value />
|
||||||
</setting>
|
</setting>
|
||||||
@ -38,6 +41,21 @@
|
|||||||
<setting name="stringToken" serializeAs="String">
|
<setting name="stringToken" serializeAs="String">
|
||||||
<value />
|
<value />
|
||||||
</setting>
|
</setting>
|
||||||
|
<setting name="InvoiceExportPath" serializeAs="String">
|
||||||
|
<value />
|
||||||
|
</setting>
|
||||||
|
<setting name="UpdateCheckBaseUrl" serializeAs="String">
|
||||||
|
<value>https://git.24pharmdata.ru</value>
|
||||||
|
</setting>
|
||||||
|
<setting name="UpdateRepo" serializeAs="String">
|
||||||
|
<value>pharmdata/elfisa-pharmacy</value>
|
||||||
|
</setting>
|
||||||
|
<setting name="UpdateApiToken" serializeAs="String">
|
||||||
|
<value />
|
||||||
|
</setting>
|
||||||
|
<setting name="UpgradeSettings" serializeAs="String">
|
||||||
|
<value>True</value>
|
||||||
|
</setting>
|
||||||
</Электронная_Фармация.Properties.Settings>
|
</Электронная_Фармация.Properties.Settings>
|
||||||
</userSettings>
|
</userSettings>
|
||||||
<runtime>
|
<runtime>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define MyAppName "Электронная Фармация"
|
#define MyAppName "Электронная Фармация"
|
||||||
#define MyAppBrand "ЭльФиСА"
|
#define MyAppBrand "ЭльФиСА"
|
||||||
#define MyAppVersion "1.0.20"
|
#define MyAppVersion "2.0.1"
|
||||||
#define MyAppPublisher "PharmData"
|
#define MyAppPublisher "PharmData"
|
||||||
#define MyAppURL "https://cdn.24pharmdata.ru"
|
#define MyAppURL "https://cdn.24pharmdata.ru"
|
||||||
#define MyAppSupportURL "https://24pharmdata.ru"
|
#define MyAppSupportURL "https://24pharmdata.ru"
|
||||||
@ -89,12 +89,10 @@ russian.BrandLine=ЭльФиСА — удобный заказ лекарств
|
|||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
; Основной выпуск Release (без отладочных PDB и XML-документации)
|
; Avalonia self-contained выпуск (весь рантайм .NET внутри — без зависимостей у клиента).
|
||||||
Source: "..\src\ElectronicPharmacy\bin\Release\*.exe"; DestDir: "{app}"; Flags: ignoreversion
|
; Сборка кладётся в ..\publish\win-x64 скриптом build-installer.ps1, exe уже переименован
|
||||||
Source: "..\src\ElectronicPharmacy\bin\Release\*.dll"; DestDir: "{app}"; Flags: ignoreversion
|
; в «Электронная Фармация.exe» (совместимо с авто-обновлением старых клиентов).
|
||||||
Source: "..\src\ElectronicPharmacy\bin\Release\*.config"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\publish\win-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
Source: "..\src\ElectronicPharmacy\bin\Release\x86\*"; DestDir: "{app}\x86"; Flags: ignoreversion recursesubdirs createallsubdirs skipifsourcedoesntexist
|
|
||||||
Source: "..\src\ElectronicPharmacy\bin\Release\x64\*"; DestDir: "{app}\x64"; Flags: ignoreversion recursesubdirs createallsubdirs skipifsourcedoesntexist
|
|
||||||
Source: "assets\elfisa.ico"; DestDir: "{app}"; DestName: "elfisa.ico"; Flags: ignoreversion
|
Source: "assets\elfisa.ico"; DestDir: "{app}"; DestName: "elfisa.ico"; Flags: ignoreversion
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
|
|||||||
BIN
installer/assets/elfisa-1024.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
installer/assets/elfisa-256.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
installer/assets/elfisa-512.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.6 KiB |
@ -15,12 +15,23 @@ if (-not $iscc) {
|
|||||||
throw "Inno Setup 6 (ISCC.exe) не найден. Установите: winget install JRSoftware.InnoSetup"
|
throw "Inno Setup 6 (ISCC.exe) не найден. Установите: winget install JRSoftware.InnoSetup"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pub = "publish\win-x64"
|
||||||
if (-not $SkipBuild) {
|
if (-not $SkipBuild) {
|
||||||
Write-Host "Сборка Release..." -ForegroundColor Cyan
|
Write-Host "Публикация Avalonia (win-x64, self-contained)..." -ForegroundColor Cyan
|
||||||
dotnet build "src\ElectronicPharmacy\ElectronicPharmacy.csproj" -c Release
|
if (Test-Path $pub) { Remove-Item $pub -Recurse -Force }
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Сборка приложения завершилась с ошибкой" }
|
dotnet publish "src\Elfisa.Avalonia\Elfisa.Avalonia.csproj" -c Release -r win-x64 `
|
||||||
|
--self-contained true -p:PublishSingleFile=false -p:DebugType=none -o $pub
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Публикация приложения завершилась с ошибкой" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Имя exe, совместимое с авто-обновлением старых WinForms-клиентов
|
||||||
|
# (батч-апдейтер перезапускает файл по имени «Электронная Фармация.exe»).
|
||||||
|
# apphost находит свой .dll независимо от имени exe, поэтому переименование безопасно.
|
||||||
|
$srcExe = Join-Path $pub "Elfisa.Avalonia.exe"
|
||||||
|
$dstExe = Join-Path $pub "Электронная Фармация.exe"
|
||||||
|
if (Test-Path $srcExe) { Move-Item $srcExe $dstExe -Force }
|
||||||
|
elseif (-not (Test-Path $dstExe)) { throw "Не найден собранный exe в $pub" }
|
||||||
|
|
||||||
Write-Host "Сборка установщика..." -ForegroundColor Cyan
|
Write-Host "Сборка установщика..." -ForegroundColor Cyan
|
||||||
& $iscc "installer\ElfisaPharmacy.iss"
|
& $iscc "installer\ElfisaPharmacy.iss"
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Inno Setup завершился с ошибкой" }
|
if ($LASTEXITCODE -ne 0) { throw "Inno Setup завершился с ошибкой" }
|
||||||
|
|||||||
@ -374,6 +374,84 @@ namespace Электронная_Фармация.Classes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отчёт покупателя: позиции его заказов за период. Сервер скоупит по JWT покупателя.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<BuyerReportLineDto>> GetBuyerReportAsync(DateTime from, DateTime to)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
|
||||||
|
var responseBody = await SendAuthorizedGetAsync(path, "формирование отчёта");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = JsonSerializer.Deserialize<BuyerReportResponse>(responseBody, JsonOptions);
|
||||||
|
return response?.Lines ?? new List<BuyerReportLineDto>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppDebugLog.Error("ApiClient", "Не удалось разобрать отчёт", ex);
|
||||||
|
throw new HttpRequestException($"Некорректный ответ сервера при формировании отчёта: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отправляет свободный запрос («промт») ИИ-сервису и получает готовый
|
||||||
|
/// документ (Word/Excel). Токен покупателя уходит в теле — сервис ходит на
|
||||||
|
/// платформу от его имени и видит ТОЛЬКО его данные.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<AiReportResult> GenerateAiReportAsync(string prompt, string format = "docx")
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
if (string.IsNullOrWhiteSpace(prompt))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Пустой запрос для ИИ.", nameof(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
var aiBase = AppConfig.AiBaseUrl.TrimEnd('/');
|
||||||
|
var fullUrl = new Uri(new Uri(aiBase + "/"), "generate").ToString();
|
||||||
|
var payload = new AiReportRequest { Prompt = prompt, Token = _token, Format = format };
|
||||||
|
var requestJson = JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
AppDebugLog.ApiRequest("POST", fullUrl, extra: $"ai prompt len={prompt.Length}, format={format}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, fullUrl))
|
||||||
|
{
|
||||||
|
httpRequest.Content = new StringContent(requestJson, Encoding.UTF8, "application/json");
|
||||||
|
var response = await _httpClient.SendAsync(httpRequest);
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errBody = await response.Content.ReadAsStringAsync();
|
||||||
|
AppDebugLog.ApiHttpError("POST", fullUrl, (int)response.StatusCode, errBody, "ai-report");
|
||||||
|
var detail = TryDeserializeError(errBody)?.Error ?? errBody;
|
||||||
|
throw new HttpRequestException($"ИИ-сервис вернул {(int)response.StatusCode}: {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
var fileName = response.Content.Headers.ContentDisposition?.FileNameStar
|
||||||
|
?? response.Content.Headers.ContentDisposition?.FileName
|
||||||
|
?? (format == "xlsx" ? "Отчёт.xlsx" : "Отчёт.docx");
|
||||||
|
fileName = fileName.Trim('"');
|
||||||
|
AppDebugLog.ApiResponse("POST", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds,
|
||||||
|
$"ai file={fileName}, {bytes.Length} bytes");
|
||||||
|
return new AiReportResult { Content = bytes, FileName = fileName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
throw WrapNetworkError(ex, "формирование ИИ-отчёта");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
|
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
@ -758,6 +836,66 @@ namespace Электронная_Фармация.Classes
|
|||||||
public List<BuyerOrderListItemDto> Orders { get; set; }
|
public List<BuyerOrderListItemDto> Orders { 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AiReportResult
|
||||||
|
{
|
||||||
|
public byte[] Content { get; set; }
|
||||||
|
public string FileName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuyerReportResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("lines")]
|
||||||
|
public List<BuyerReportLineDto> 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 BuyerLocationDto
|
public sealed class BuyerLocationDto
|
||||||
{
|
{
|
||||||
[JsonPropertyName("buyer_location_id")]
|
[JsonPropertyName("buyer_location_id")]
|
||||||
|
|||||||
@ -17,6 +17,10 @@ namespace Электронная_Фармация.Classes
|
|||||||
public const string DefaultInstallerDownloadUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe";
|
public const string DefaultInstallerDownloadUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe";
|
||||||
private const string LegacyApiBaseUrl = "http://195.34.241.84:9988";
|
private const string LegacyApiBaseUrl = "http://195.34.241.84:9988";
|
||||||
private const string ApiBaseUrlEnvVar = "ELF_API_BASE_URL";
|
private const string ApiBaseUrlEnvVar = "ELF_API_BASE_URL";
|
||||||
|
/// <summary>ИИ-сервис отчётов (проксируется Caddy на Мак-мини). Можно переопределить
|
||||||
|
/// переменной ELF_AI_BASE_URL, напр. http://192.168.95.116:8808 для теста по локальной сети.</summary>
|
||||||
|
public const string DefaultAiBaseUrl = "https://24pharmdata.ru/ai";
|
||||||
|
private const string AiBaseUrlEnvVar = "ELF_AI_BASE_URL";
|
||||||
private static bool _sqliteInteropPrepared;
|
private static bool _sqliteInteropPrepared;
|
||||||
private static readonly object _sqliteInteropLock = new object();
|
private static readonly object _sqliteInteropLock = new object();
|
||||||
|
|
||||||
@ -111,6 +115,21 @@ namespace Электронная_Фармация.Classes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Базовый URL ИИ-сервиса отчётов.</summary>
|
||||||
|
public static string AiBaseUrl
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var fromEnv = Environment.GetEnvironmentVariable(AiBaseUrlEnvVar);
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||||
|
{
|
||||||
|
return fromEnv.Trim().TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultAiBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static string NormalizeApiBaseUrl(string url)
|
public static string NormalizeApiBaseUrl(string url)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
|||||||
@ -74,6 +74,24 @@ namespace Электронная_Фармация.Classes
|
|||||||
throw new InvalidOperationException("В архиве не найден exe приложения.");
|
throw new InvalidOperationException("В архиве не найден exe приложения.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Переносим payload в путь без спецсимволов: имя корневой папки в архиве
|
||||||
|
// бывает в чужой кодировке, и такой путь ломает аргумент SRC у robocopy
|
||||||
|
// (особенно при запуске под elevated cmd с OEM-кодовой страницей).
|
||||||
|
var asciiPayload = Path.Combine(workRoot, "payload");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(asciiPayload))
|
||||||
|
{
|
||||||
|
Directory.Delete(asciiPayload, true);
|
||||||
|
}
|
||||||
|
Directory.Move(payloadDir, asciiPayload);
|
||||||
|
payloadDir = asciiPayload;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Если перенос не удался — работаем с исходным путём.
|
||||||
|
}
|
||||||
|
|
||||||
var needsElevation = !IsDirectoryWritable(installDir);
|
var needsElevation = !IsDirectoryWritable(installDir);
|
||||||
var updaterBat = Path.Combine(workRoot, "apply-update.cmd");
|
var updaterBat = Path.Combine(workRoot, "apply-update.cmd");
|
||||||
WriteUpdaterScript(updaterBat);
|
WriteUpdaterScript(updaterBat);
|
||||||
@ -82,12 +100,12 @@ namespace Электронная_Фармация.Classes
|
|||||||
? "Нужны права администратора для установки в Program Files..."
|
? "Нужны права администратора для установки в Program Files..."
|
||||||
: "Подготовка перезапуска...");
|
: "Подготовка перезапуска...");
|
||||||
|
|
||||||
|
var batchArgs = Quote(payloadDir) + " " + Quote(installDir) + " " +
|
||||||
|
Quote(Path.GetFileName(currentExe)) + " " +
|
||||||
|
Process.GetCurrentProcess().Id;
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = updaterBat,
|
|
||||||
Arguments = Quote(payloadDir) + " " + Quote(installDir) + " " +
|
|
||||||
Quote(Path.GetFileName(currentExe)) + " " +
|
|
||||||
Process.GetCurrentProcess().Id,
|
|
||||||
UseShellExecute = true,
|
UseShellExecute = true,
|
||||||
WorkingDirectory = workRoot,
|
WorkingDirectory = workRoot,
|
||||||
WindowStyle = needsElevation ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden,
|
WindowStyle = needsElevation ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden,
|
||||||
@ -95,9 +113,19 @@ namespace Электронная_Фармация.Classes
|
|||||||
};
|
};
|
||||||
if (needsElevation)
|
if (needsElevation)
|
||||||
{
|
{
|
||||||
// UAC prompt — без этого robocopy в Program Files молча не заменяет exe.
|
// ВАЖНО: повышаем права через cmd.exe (PE-исполняемый), а НЕ через
|
||||||
|
// .cmd-файл напрямую. ShellExecute с verb=runas на .cmd срабатывает не
|
||||||
|
// на всех системах (у batch может не быть runas-ассоциации), из-за чего
|
||||||
|
// elevated-процесс молча не запускается и обновление не применяется.
|
||||||
|
psi.FileName = "cmd.exe";
|
||||||
|
psi.Arguments = "/c \"\"" + updaterBat + "\" " + batchArgs + "\"";
|
||||||
psi.Verb = "runas";
|
psi.Verb = "runas";
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
psi.FileName = updaterBat;
|
||||||
|
psi.Arguments = batchArgs;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@ -248,6 +248,9 @@
|
|||||||
<Compile Include="UserControls\UCRefusals.cs">
|
<Compile Include="UserControls\UCRefusals.cs">
|
||||||
<SubType>UserControl</SubType>
|
<SubType>UserControl</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
<Compile Include="UserControls\UCReports.cs">
|
||||||
|
<SubType>UserControl</SubType>
|
||||||
|
</Compile>
|
||||||
<Compile Include="UserControls\UCPriceList.cs">
|
<Compile Include="UserControls\UCPriceList.cs">
|
||||||
<SubType>UserControl</SubType>
|
<SubType>UserControl</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
|||||||
@ -61,6 +61,7 @@ namespace Электронная_Фармация
|
|||||||
new SidebarItem { Key = "price", Text = "Прайс-лист", IconGlyph = "\uE14C" },
|
new SidebarItem { Key = "price", Text = "Прайс-лист", IconGlyph = "\uE14C" },
|
||||||
new SidebarItem { Key = "orders", Text = "Заказы", IconGlyph = "\uE7BF" },
|
new SidebarItem { Key = "orders", Text = "Заказы", IconGlyph = "\uE7BF" },
|
||||||
new SidebarItem { Key = "invoices", Text = "Накладные", IconGlyph = "\uE8A5" },
|
new SidebarItem { Key = "invoices", Text = "Накладные", IconGlyph = "\uE8A5" },
|
||||||
|
new SidebarItem { Key = "reports", Text = "Отчёты", IconGlyph = "" },
|
||||||
new SidebarItem { Key = "upload", Text = "Загрузить", IconGlyph = "\uE896" },
|
new SidebarItem { Key = "upload", Text = "Загрузить", IconGlyph = "\uE896" },
|
||||||
new SidebarItem { Key = "send", Text = "Отправить", IconGlyph = "\uE725" },
|
new SidebarItem { Key = "send", Text = "Отправить", IconGlyph = "\uE725" },
|
||||||
new SidebarItem { Key = "refusals", Text = "Отказы", IconGlyph = "\uE7BA" },
|
new SidebarItem { Key = "refusals", Text = "Отказы", IconGlyph = "\uE7BA" },
|
||||||
@ -321,6 +322,9 @@ namespace Электронная_Фармация
|
|||||||
case "invoices":
|
case "invoices":
|
||||||
OpenInvoicesTab();
|
OpenInvoicesTab();
|
||||||
break;
|
break;
|
||||||
|
case "reports":
|
||||||
|
OpenReportsTab();
|
||||||
|
break;
|
||||||
case "upload":
|
case "upload":
|
||||||
tsBtnDownloadData_Click(this, EventArgs.Empty);
|
tsBtnDownloadData_Click(this, EventArgs.Empty);
|
||||||
_appHeader.SetActive("upload");
|
_appHeader.SetActive("upload");
|
||||||
@ -499,6 +503,12 @@ namespace Электронная_Фармация
|
|||||||
OpenDocumentTab("Отказы", "refusals", refusals, allowDuplicate: false);
|
OpenDocumentTab("Отказы", "refusals", refusals, allowDuplicate: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OpenReportsTab()
|
||||||
|
{
|
||||||
|
var reports = new UCReports();
|
||||||
|
OpenDocumentTab("Отчёты", "reports", reports, allowDuplicate: false);
|
||||||
|
}
|
||||||
|
|
||||||
partial void ShowShellLoading(string message)
|
partial void ShowShellLoading(string message)
|
||||||
{
|
{
|
||||||
_loadingOverlay?.Show(message);
|
_loadingOverlay?.Show(message);
|
||||||
|
|||||||
@ -32,6 +32,6 @@ using System.Runtime.InteropServices;
|
|||||||
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
|
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
|
||||||
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ:
|
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ:
|
||||||
// [assembly: AssemblyVersion("1.0.*")]
|
// [assembly: AssemblyVersion("1.0.*")]
|
||||||
[assembly: AssemblyVersion("1.0.23.0")]
|
[assembly: AssemblyVersion("1.0.29.0")]
|
||||||
[assembly: AssemblyFileVersion("1.0.23.0")]
|
[assembly: AssemblyFileVersion("1.0.29.0")]
|
||||||
|
|
||||||
|
|||||||
318
src/ElectronicPharmacy/UserControls/UCReports.cs
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using Электронная_Фармация.Classes;
|
||||||
|
using Электронная_Фармация.Properties;
|
||||||
|
|
||||||
|
namespace Электронная_Фармация.UserControls
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Раздел «Отчёты» покупателя. Данные тянутся с сервера (/api/buyer/report),
|
||||||
|
/// который скоупит их по JWT покупателя — клиент видит ТОЛЬКО свои заказы.
|
||||||
|
/// Все 4 отчёта агрегируются на клиенте из одного набора строк.
|
||||||
|
/// </summary>
|
||||||
|
public class UCReports : UserControl
|
||||||
|
{
|
||||||
|
private readonly DateTimePicker _dtFrom;
|
||||||
|
private readonly DateTimePicker _dtTo;
|
||||||
|
private readonly ComboBox _cboReport;
|
||||||
|
private readonly Button _btnBuild;
|
||||||
|
private readonly Label _lblTotals;
|
||||||
|
private readonly DataGridView _grid;
|
||||||
|
private readonly TextBox _txtPrompt;
|
||||||
|
private readonly ComboBox _cboFormat;
|
||||||
|
private readonly Button _btnAi;
|
||||||
|
private List<BuyerReportLineDto> _lines = new List<BuyerReportLineDto>();
|
||||||
|
|
||||||
|
// Палитра под общий стиль приложения (бирюза шапки #0F766E).
|
||||||
|
private static readonly Color Accent = Color.FromArgb(15, 118, 110);
|
||||||
|
private static readonly Color AccentHover = Color.FromArgb(13, 148, 136);
|
||||||
|
private static readonly Color TextMain = Color.FromArgb(31, 41, 55);
|
||||||
|
private static readonly Color TextMuted = Color.FromArgb(120, 128, 134);
|
||||||
|
private static readonly Color RowAlt = Color.FromArgb(247, 250, 250);
|
||||||
|
private static readonly Color SelBack = Color.FromArgb(178, 240, 230);
|
||||||
|
private static readonly Color GridLine = Color.FromArgb(230, 234, 236);
|
||||||
|
|
||||||
|
private static void StylePrimaryButton(Button b)
|
||||||
|
{
|
||||||
|
b.FlatStyle = FlatStyle.Flat;
|
||||||
|
b.FlatAppearance.BorderSize = 0;
|
||||||
|
b.FlatAppearance.MouseOverBackColor = AccentHover;
|
||||||
|
b.FlatAppearance.MouseDownBackColor = AccentHover;
|
||||||
|
b.BackColor = Accent;
|
||||||
|
b.ForeColor = Color.White;
|
||||||
|
b.Cursor = Cursors.Hand;
|
||||||
|
b.Font = new Font("Segoe UI", 9F, FontStyle.Regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UCReports()
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill;
|
||||||
|
|
||||||
|
var uiFont = new Font("Segoe UI", 9F);
|
||||||
|
var top = new Panel { Dock = DockStyle.Top, Height = 100, BackColor = Color.White };
|
||||||
|
|
||||||
|
// --- строка 1: период + готовый отчёт по данным ---
|
||||||
|
var lblFrom = new Label { Text = "С", AutoSize = true, Left = 12, Top = 17, ForeColor = TextMain, Font = uiFont };
|
||||||
|
_dtFrom = new DateTimePicker { Format = DateTimePickerFormat.Short, Width = 112, Left = 32, Top = 13, Value = DateTime.Today.AddMonths(-1), Font = uiFont };
|
||||||
|
var lblTo = new Label { Text = "по", AutoSize = true, Left = 150, Top = 17, ForeColor = TextMain, Font = uiFont };
|
||||||
|
_dtTo = new DateTimePicker { Format = DateTimePickerFormat.Short, Width = 112, Left = 176, Top = 13, Value = DateTime.Today, Font = uiFont };
|
||||||
|
_cboReport = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 190, Left = 298, Top = 13, Font = uiFont, FlatStyle = FlatStyle.Flat };
|
||||||
|
_cboReport.Items.AddRange(new object[] { "Мои заказы", "По поставщикам", "По товарам", "По аптекам" });
|
||||||
|
_cboReport.SelectedIndex = 0;
|
||||||
|
_btnBuild = new Button { Text = "Сформировать", Left = 498, Top = 12, Width = 140, Height = 28 };
|
||||||
|
_lblTotals = new Label { AutoSize = true, Left = 654, Top = 17, Text = "", ForeColor = Accent, Font = new Font("Segoe UI", 9F, FontStyle.Bold) };
|
||||||
|
|
||||||
|
top.Controls.AddRange(new Control[] { lblFrom, _dtFrom, lblTo, _dtTo, _cboReport, _btnBuild, _lblTotals });
|
||||||
|
|
||||||
|
// --- строка 2: свободный запрос к ИИ (клиент пишет словами -> Word/Excel) ---
|
||||||
|
var lblAi = new Label { Text = "Запрос ИИ:", AutoSize = true, Left = 12, Top = 61, ForeColor = TextMain, Font = uiFont };
|
||||||
|
_txtPrompt = new TextBox { Left = 92, Top = 57, Width = 470, Font = uiFont, BorderStyle = BorderStyle.FixedSingle };
|
||||||
|
_cboFormat = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Left = 574, Top = 57, Width = 92, Font = uiFont, FlatStyle = FlatStyle.Flat };
|
||||||
|
_cboFormat.Items.AddRange(new object[] { "Word", "Excel" });
|
||||||
|
_cboFormat.SelectedIndex = 0;
|
||||||
|
_btnAi = new Button { Text = "🤖 Сформировать ИИ", Left = 676, Top = 56, Width = 170, Height = 28 };
|
||||||
|
_btnAi.Text = "Сформировать ИИ"; // без эмодзи (криво рендерился в кнопке)
|
||||||
|
var lblHint = new Label { AutoSize = true, Left = 92, Top = 83, ForeColor = TextMuted, Font = new Font("Segoe UI", 8F),
|
||||||
|
Text = "Напр.: «закупки по поставщикам за июль», «топ товаров за 3 месяца», «заказы у Farmsnab»" };
|
||||||
|
|
||||||
|
StylePrimaryButton(_btnBuild);
|
||||||
|
StylePrimaryButton(_btnAi);
|
||||||
|
|
||||||
|
var tip = new ToolTip();
|
||||||
|
tip.SetToolTip(_txtPrompt, "Опишите нужный отчёт словами — ИИ подберёт тип, период и соберёт документ.");
|
||||||
|
|
||||||
|
top.Controls.AddRange(new Control[] { lblAi, _txtPrompt, _cboFormat, _btnAi, lblHint });
|
||||||
|
|
||||||
|
_grid = new DataGridView
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
ReadOnly = true,
|
||||||
|
AllowUserToAddRows = false,
|
||||||
|
AllowUserToDeleteRows = false,
|
||||||
|
AllowUserToResizeRows = false,
|
||||||
|
RowHeadersVisible = false,
|
||||||
|
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||||
|
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
||||||
|
BorderStyle = BorderStyle.None,
|
||||||
|
BackgroundColor = Color.White,
|
||||||
|
EnableHeadersVisualStyles = false,
|
||||||
|
ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None,
|
||||||
|
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing,
|
||||||
|
ColumnHeadersHeight = 34,
|
||||||
|
CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal,
|
||||||
|
GridColor = GridLine,
|
||||||
|
RowHeadersBorderStyle = DataGridViewHeaderBorderStyle.None
|
||||||
|
};
|
||||||
|
_grid.ColumnHeadersDefaultCellStyle.BackColor = Accent;
|
||||||
|
_grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.White;
|
||||||
|
_grid.ColumnHeadersDefaultCellStyle.Font = new Font("Segoe UI", 9.5F, FontStyle.Bold);
|
||||||
|
_grid.ColumnHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleLeft;
|
||||||
|
_grid.ColumnHeadersDefaultCellStyle.Padding = new Padding(6, 0, 0, 0);
|
||||||
|
_grid.DefaultCellStyle.Font = new Font("Segoe UI", 9.5F);
|
||||||
|
_grid.DefaultCellStyle.ForeColor = TextMain;
|
||||||
|
_grid.DefaultCellStyle.SelectionBackColor = SelBack;
|
||||||
|
_grid.DefaultCellStyle.SelectionForeColor = TextMain;
|
||||||
|
_grid.DefaultCellStyle.Padding = new Padding(6, 0, 6, 0);
|
||||||
|
_grid.AlternatingRowsDefaultCellStyle.BackColor = RowAlt;
|
||||||
|
_grid.RowTemplate.Height = 30;
|
||||||
|
|
||||||
|
Controls.Add(_grid);
|
||||||
|
Controls.Add(top);
|
||||||
|
|
||||||
|
_btnBuild.Click += async (_, __) => await LoadAsync();
|
||||||
|
_cboReport.SelectedIndexChanged += (_, __) => Render();
|
||||||
|
_btnAi.Click += async (_, __) => await BuildAiAsync();
|
||||||
|
_txtPrompt.KeyDown += async (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Keys.Enter) { e.SuppressKeyPress = true; await BuildAiAsync(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BuildAiAsync()
|
||||||
|
{
|
||||||
|
var prompt = _txtPrompt.Text?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(prompt))
|
||||||
|
{
|
||||||
|
ToastNotification.ShowError("Введите запрос для ИИ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = Settings.Default.stringToken;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
ToastNotification.ShowError("Не найден токен авторизации. Войдите в систему.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_btnAi.Enabled = false;
|
||||||
|
_btnAi.Text = "Формирую…";
|
||||||
|
|
||||||
|
var fmt = _cboFormat.SelectedIndex == 1 ? "xlsx" : "docx";
|
||||||
|
var client = new ApiClient();
|
||||||
|
client.SetToken(token);
|
||||||
|
var result = await client.GenerateAiReportAsync(prompt, fmt);
|
||||||
|
|
||||||
|
var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||||
|
var safe = string.Join("_", (result.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, result.Content);
|
||||||
|
ToastNotification.ShowCustom("Отчёт готов, открываю…", Color.SeaGreen, Color.White);
|
||||||
|
Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppDebugLog.Error("UCReports", "Ошибка ИИ-отчёта", ex);
|
||||||
|
ToastNotification.ShowError("Ошибка ИИ-отчёта: " + ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_btnAi.Enabled = true;
|
||||||
|
_btnAi.Text = "🤖 Сформировать ИИ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_btnBuild.Enabled = false;
|
||||||
|
var token = Settings.Default.stringToken;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
ToastNotification.ShowError("Не найден токен авторизации. Войдите в систему.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = new ApiClient();
|
||||||
|
client.SetToken(token);
|
||||||
|
_lines = await client.GetBuyerReportAsync(_dtFrom.Value.Date, _dtTo.Value.Date)
|
||||||
|
?? new List<BuyerReportLineDto>();
|
||||||
|
Render();
|
||||||
|
|
||||||
|
if (_lines.Count == 0)
|
||||||
|
{
|
||||||
|
ToastNotification.ShowCustom("За выбранный период заказов нет.", Color.DarkOrange, Color.White);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppDebugLog.Error("UCReports", "Ошибка формирования отчёта", ex);
|
||||||
|
ToastNotification.ShowError("Ошибка отчёта: " + ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_btnBuild.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Render()
|
||||||
|
{
|
||||||
|
var dt = new DataTable();
|
||||||
|
|
||||||
|
switch (_cboReport.SelectedIndex)
|
||||||
|
{
|
||||||
|
case 0: // Мои заказы (по заказу)
|
||||||
|
dt.Columns.Add("Дата", typeof(string));
|
||||||
|
dt.Columns.Add("Номер", typeof(string));
|
||||||
|
dt.Columns.Add("Поставщик", typeof(string));
|
||||||
|
dt.Columns.Add("Аптека", typeof(string));
|
||||||
|
dt.Columns.Add("Позиций", typeof(int));
|
||||||
|
dt.Columns.Add("Сумма", typeof(decimal));
|
||||||
|
dt.Columns.Add("Статус", typeof(string));
|
||||||
|
foreach (var g in _lines.GroupBy(l => l.OrderID)
|
||||||
|
.OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue))
|
||||||
|
{
|
||||||
|
var f = g.First();
|
||||||
|
var suppliers = string.Join(", ", g.Select(x => x.Supplier)
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct());
|
||||||
|
dt.Rows.Add(
|
||||||
|
f.OrderDate?.ToLocalTime().ToString("dd.MM.yyyy") ?? "",
|
||||||
|
f.GlobalSign,
|
||||||
|
suppliers,
|
||||||
|
f.Location,
|
||||||
|
g.Count(),
|
||||||
|
g.Sum(x => x.Sum),
|
||||||
|
f.Status);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1: // По поставщикам
|
||||||
|
dt.Columns.Add("Поставщик", typeof(string));
|
||||||
|
dt.Columns.Add("Заказов", typeof(int));
|
||||||
|
dt.Columns.Add("Позиций", typeof(int));
|
||||||
|
dt.Columns.Add("Сумма", typeof(decimal));
|
||||||
|
foreach (var g in _lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Supplier) ? "(не указан)" : l.Supplier)
|
||||||
|
.OrderByDescending(x => x.Sum(y => y.Sum)))
|
||||||
|
{
|
||||||
|
dt.Rows.Add(g.Key, g.Select(x => x.OrderID).Distinct().Count(), g.Count(), g.Sum(x => x.Sum));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // По товарам
|
||||||
|
dt.Columns.Add("Товар", typeof(string));
|
||||||
|
dt.Columns.Add("Код", typeof(string));
|
||||||
|
dt.Columns.Add("Кол-во", typeof(decimal));
|
||||||
|
dt.Columns.Add("Сумма", typeof(decimal));
|
||||||
|
foreach (var g in _lines.GroupBy(l => ((l.ItemName ?? "").Trim() + "|" + (l.ItemCode ?? "")))
|
||||||
|
.OrderByDescending(x => x.Sum(y => y.Sum)))
|
||||||
|
{
|
||||||
|
var f = g.First();
|
||||||
|
dt.Rows.Add(f.ItemName, f.ItemCode, g.Sum(x => x.Qty), g.Sum(x => x.Sum));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // По аптекам (грузополучателям)
|
||||||
|
dt.Columns.Add("Аптека", typeof(string));
|
||||||
|
dt.Columns.Add("Заказов", typeof(int));
|
||||||
|
dt.Columns.Add("Позиций", typeof(int));
|
||||||
|
dt.Columns.Add("Сумма", typeof(decimal));
|
||||||
|
foreach (var g in _lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Location) ? "(не указана)" : l.Location)
|
||||||
|
.OrderByDescending(x => x.Sum(y => y.Sum)))
|
||||||
|
{
|
||||||
|
dt.Rows.Add(g.Key, g.Select(x => x.OrderID).Distinct().Count(), g.Count(), g.Sum(x => x.Sum));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_grid.DataSource = dt;
|
||||||
|
foreach (DataGridViewColumn c in _grid.Columns)
|
||||||
|
{
|
||||||
|
bool numeric = c.Name == "Сумма" || c.Name == "Кол-во" || c.Name == "Позиций" || c.Name == "Заказов";
|
||||||
|
if (c.Name == "Сумма") c.DefaultCellStyle.Format = "N2";
|
||||||
|
if (c.Name == "Кол-во") c.DefaultCellStyle.Format = "N3";
|
||||||
|
if (numeric)
|
||||||
|
{
|
||||||
|
c.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight;
|
||||||
|
c.HeaderCell.Style.Alignment = DataGridViewContentAlignment.MiddleRight;
|
||||||
|
c.HeaderCell.Style.Padding = new Padding(0, 0, 6, 0);
|
||||||
|
}
|
||||||
|
// Узкие числовые колонки, широкие — текстовые (Fill распределит по весу).
|
||||||
|
if (c.Name == "Позиций" || c.Name == "Заказов") c.FillWeight = 45;
|
||||||
|
else if (c.Name == "Сумма" || c.Name == "Кол-во") c.FillWeight = 70;
|
||||||
|
else if (c.Name == "Дата" || c.Name == "Статус" || c.Name == "Код") c.FillWeight = 75;
|
||||||
|
else if (c.Name == "Номер") c.FillWeight = 90;
|
||||||
|
else c.FillWeight = 150; // Поставщик / Товар / Аптека
|
||||||
|
}
|
||||||
|
|
||||||
|
int orders = _lines.Select(l => l.OrderID).Distinct().Count();
|
||||||
|
decimal totalSum = _lines.Sum(l => l.Sum);
|
||||||
|
_lblTotals.Text = $"Заказов: {orders} Позиций: {_lines.Count} Сумма: {totalSum:N2}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Elfisa.Avalonia/App.axaml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Elfisa.Avalonia.App"
|
||||||
|
xmlns:local="using:Elfisa.Avalonia"
|
||||||
|
RequestedThemeVariant="Light">
|
||||||
|
|
||||||
|
<Application.DataTemplates>
|
||||||
|
<local:ViewLocator/>
|
||||||
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.ThemeDictionaries>
|
||||||
|
<ResourceDictionary x:Key="Light">
|
||||||
|
<SolidColorBrush x:Key="Bg" Color="#EDF2F2"/>
|
||||||
|
<SolidColorBrush x:Key="Surface" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="Text" Color="#1F2933"/>
|
||||||
|
<SolidColorBrush x:Key="Muted" Color="#6B7785"/>
|
||||||
|
<SolidColorBrush x:Key="Border" Color="#E1E7E8"/>
|
||||||
|
<SolidColorBrush x:Key="RowAlt" Color="#F5F9F9"/>
|
||||||
|
<SolidColorBrush x:Key="InputBg" Color="#FFFFFF"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
<ResourceDictionary x:Key="Dark">
|
||||||
|
<SolidColorBrush x:Key="Bg" Color="#0E1416"/>
|
||||||
|
<SolidColorBrush x:Key="Surface" Color="#182226"/>
|
||||||
|
<SolidColorBrush x:Key="Text" Color="#E7EDEE"/>
|
||||||
|
<SolidColorBrush x:Key="Muted" Color="#8FA0A5"/>
|
||||||
|
<SolidColorBrush x:Key="Border" Color="#28353A"/>
|
||||||
|
<SolidColorBrush x:Key="RowAlt" Color="#141D20"/>
|
||||||
|
<SolidColorBrush x:Key="InputBg" Color="#0F1719"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</ResourceDictionary.ThemeDictionaries>
|
||||||
|
|
||||||
|
<!-- Акцент бренда (#0F766E — бирюза шапки), постоянен в обеих темах -->
|
||||||
|
<Color x:Key="AccentColor">#0F766E</Color>
|
||||||
|
<SolidColorBrush x:Key="Accent" Color="#0F766E"/>
|
||||||
|
<SolidColorBrush x:Key="AccentHover" Color="#0D9488"/>
|
||||||
|
<SolidColorBrush x:Key="AccentPressed" Color="#115E59"/>
|
||||||
|
<SolidColorBrush x:Key="AccentSoft" Color="#CCFBF1"/>
|
||||||
|
<SolidColorBrush x:Key="Sidebar" Color="#0E5F58"/>
|
||||||
|
<SolidColorBrush x:Key="SidebarActive" Color="#0A4A44"/>
|
||||||
|
<SolidColorBrush x:Key="SidebarText" Color="#D5F5F0"/>
|
||||||
|
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||||
|
<StyleInclude Source="/Styles/Elfisa.axaml"/>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
35
src/Elfisa.Avalonia/App.axaml.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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()
|
||||||
|
{
|
||||||
|
RequestedThemeVariant = Elfisa.Core.SettingsStore.Current.Theme == "Dark"
|
||||||
|
? global::Avalonia.Styling.ThemeVariant.Dark
|
||||||
|
: global::Avalonia.Styling.ThemeVariant.Light;
|
||||||
|
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = new RootViewModel(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/Elfisa.Avalonia/Assets/avalonia-logo.ico
Normal file
|
After Width: | Height: | Size: 172 KiB |
38
src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<Version>2.0.1</Version>
|
||||||
|
<AssemblyVersion>2.0.1.0</AssemblyVersion>
|
||||||
|
<FileVersion>2.0.1.0</FileVersion>
|
||||||
|
<Product>ЭльФиСА — Электронная Фармация</Product>
|
||||||
|
<Company>PharmData</Company>
|
||||||
|
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
|
||||||
|
<SatelliteResourceLanguages>ru;en</SatelliteResourceLanguages>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Models\" />
|
||||||
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="12.1.0" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.0" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||||
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||||
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Elfisa.Core\Elfisa.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
24
src/Elfisa.Avalonia/Program.cs
Normal file
@ -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<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
#if DEBUG
|
||||||
|
.WithDeveloperTools()
|
||||||
|
#endif
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
73
src/Elfisa.Avalonia/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# ЭльФиСА — кросс-платформенный клиент (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`.
|
||||||
|
|
||||||
|
| Раздел | Что нужно | Сложность |
|
||||||
|
|---|---|---|
|
||||||
|
| ✅ Прайс-лист | поиск + грид (/api/supplier-prices/summary) | готово (без добавления в заказ) |
|
||||||
|
| ✅ Заказы | список + карточка заказа с позициями | готово |
|
||||||
|
| ✅ Накладные | реестр приходных документов | готово |
|
||||||
|
| ✅ Отчёты | 4 типа + ИИ → Word/Excel | готово |
|
||||||
|
| ✅ Справочник | профиль + грузополучатели | готово |
|
||||||
|
| ✅ Отправить | поиск → корзина → создание заказа (POST /api/buyer/orders) | готов UI; реальная отправка не тестировалась (создаёт заказ на проде) |
|
||||||
|
| ⏳ Отказы | список отказных позиций | нужен серверный эндпоинт (RefuseItemsCount есть только в локальной БД WinForms) |
|
||||||
|
| ⏳ Автообновление | аналог AutoUpdater под каждую ОС | не начато |
|
||||||
|
|
||||||
|
**Прайс-лист/Заказы/Накладные** строятся на рабочих эндпоинтах
|
||||||
|
(`supplier-prices/summary`, `buyer/report`). «Родные» `buyer/orders` и
|
||||||
|
`buyer/catalog` возвращают 500 (баг квотинга PascalCase в GORM-билдере).
|
||||||
|
Фикс для **buyer/orders** готов и застейджен (`es_api_service.exe.new`,
|
||||||
|
apply-new-exe.bat) — после деплоя Заказы/Накладные перейдут на него.
|
||||||
|
Каталог не используется (берём рабочий summary), его фикс — по желанию.
|
||||||
|
|
||||||
|
Осталось: **Отправить** (корзина + создание заказа + DBF) и **Отказы**,
|
||||||
|
плюс автообновление и сохранение сессии. Логика переносится из WinForms,
|
||||||
|
переписывается только UI (XAML).
|
||||||
193
src/Elfisa.Avalonia/Styles/Elfisa.axaml
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- ============ Типографика ============ -->
|
||||||
|
<Style Selector="TextBlock.h1">
|
||||||
|
<Setter Property="FontSize" Value="22"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.h2">
|
||||||
|
<Setter Property="FontSize" Value="16"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.muted">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
|
||||||
|
<Setter Property="FontSize" Value="12.5"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.label">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============ Карточка ============ -->
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Surface}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="12"/>
|
||||||
|
<Setter Property="Padding" Value="18"/>
|
||||||
|
<Setter Property="BoxShadow" Value="0 2 14 0 #12000000"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============ Кнопки ============ -->
|
||||||
|
<Style Selector="Button.primary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Accent}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OnAccent}"/>
|
||||||
|
<Setter Property="Padding" Value="18,10"/>
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="FontSize" Value="13.5"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentHover}"/>
|
||||||
|
<Setter Property="TextBlock.Foreground" Value="{DynamicResource OnAccent}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.primary:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentPressed}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.primary:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Muted}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.ghost">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Accent}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Padding" Value="16,9"/>
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentSoft}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============ Поля ввода ============ -->
|
||||||
|
<Style Selector="TextBox">
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="Padding" Value="10,8"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||||
|
<Setter Property="MinHeight" Value="38"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ComboBox">
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="MinHeight" Value="38"/>
|
||||||
|
<Setter Property="Padding" Value="10,6"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DatePicker">
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="MinHeight" Value="38"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============ Сайдбар-навигация ============ -->
|
||||||
|
<Style Selector="ListBox.nav">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.nav ListBoxItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SidebarText}"/>
|
||||||
|
<Setter Property="Padding" Value="14,11"/>
|
||||||
|
<Setter Property="Margin" Value="10,2"/>
|
||||||
|
<Setter Property="CornerRadius" Value="9"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.nav ListBoxItem:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.nav ListBoxItem:selected /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.nav ListBoxItem:selected">
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============ Верхняя навигация (как в WinForms-версии) ============ -->
|
||||||
|
<Style Selector="ListBox.topnav">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.topnav ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="6,7"/>
|
||||||
|
<Setter Property="Margin" Value="2,0"/>
|
||||||
|
<Setter Property="CornerRadius" Value="10"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.topnav ListBoxItem:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.topnav ListBoxItem:selected /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============ Вкладки-документы ============ -->
|
||||||
|
<Style Selector="ListBox.tabs">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tabs ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="12,6"/>
|
||||||
|
<Setter Property="Margin" Value="3,6"/>
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tabs ListBoxItem:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource RowAlt}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tabs ListBoxItem:selected /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentSoft}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tabs ListBoxItem:selected">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AccentPressed}"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============ Таблица ============ -->
|
||||||
|
<Style Selector="DataGrid">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Surface}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
|
||||||
|
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Border}"/>
|
||||||
|
<Setter Property="RowBackground" Value="{DynamicResource Surface}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||||
|
<Setter Property="RowHeight" Value="34"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGridColumnHeader">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Accent}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource OnAccent}"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="Padding" Value="10,8"/>
|
||||||
|
<Setter Property="MinHeight" Value="36"/>
|
||||||
|
<Setter Property="SeparatorBrush" Value="#2FFFFFFF"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGridCell">
|
||||||
|
<Setter Property="Padding" Value="10,0"/>
|
||||||
|
</Style>
|
||||||
|
<!-- числовые ячейки — вправо -->
|
||||||
|
<Style Selector="DataGridCell.num TextBlock">
|
||||||
|
<Setter Property="TextAlignment" Value="Right"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGridRow:selected /template/ Rectangle#BackgroundRectangle">
|
||||||
|
<Setter Property="Fill" Value="{DynamicResource AccentSoft}"/>
|
||||||
|
<Setter Property="Opacity" Value="1"/>
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
37
src/Elfisa.Avalonia/ViewLocator.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
using Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Given a view model, returns the corresponding view if possible.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/Elfisa.Avalonia/ViewModels/CartService.cs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public partial class CartItem : ObservableObject
|
||||||
|
{
|
||||||
|
public PriceItem Source { get; }
|
||||||
|
public string Name => Source.DrugName ?? "";
|
||||||
|
public string Supplier => Source.SupplierName ?? "";
|
||||||
|
public decimal Price => Source.Price;
|
||||||
|
|
||||||
|
[ObservableProperty] private decimal _qty = 1;
|
||||||
|
public decimal LineSum => Price * Qty;
|
||||||
|
|
||||||
|
public event Action? Changed;
|
||||||
|
|
||||||
|
public CartItem(PriceItem src) => Source = src;
|
||||||
|
|
||||||
|
partial void OnQtyChanged(decimal value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(LineSum));
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Единая корзина заказа — общая для «Прайс-листа» и «Отправить».</summary>
|
||||||
|
public sealed class CartService
|
||||||
|
{
|
||||||
|
public static CartService Instance { get; } = new();
|
||||||
|
|
||||||
|
public ObservableCollection<CartItem> Items { get; } = new();
|
||||||
|
public event Action? Changed;
|
||||||
|
|
||||||
|
public decimal Total => Items.Sum(c => c.LineSum);
|
||||||
|
public int Count => Items.Count;
|
||||||
|
|
||||||
|
public void Add(PriceItem src, decimal qty = 1)
|
||||||
|
{
|
||||||
|
if (src is null || string.IsNullOrEmpty(src.SupplierPriceId)) return;
|
||||||
|
var existing = Items.FirstOrDefault(c => c.Source.SupplierPriceId == src.SupplierPriceId);
|
||||||
|
if (existing != null) existing.Qty += qty;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ci = new CartItem(src) { Qty = qty };
|
||||||
|
ci.Changed += Raise;
|
||||||
|
Items.Add(ci);
|
||||||
|
}
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(CartItem item)
|
||||||
|
{
|
||||||
|
if (item is null) return;
|
||||||
|
item.Changed -= Raise;
|
||||||
|
Items.Remove(item);
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
foreach (var i in Items) i.Changed -= Raise;
|
||||||
|
Items.Clear();
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Raise() => Changed?.Invoke();
|
||||||
|
}
|
||||||
48
src/Elfisa.Avalonia/ViewModels/DirectoryViewModel.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>Справочник покупателя: профиль + грузополучатели (адреса).</summary>
|
||||||
|
public partial class DirectoryViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
|
||||||
|
public string UserName => Session.Current.Username ?? "";
|
||||||
|
public string RoleLabel => Session.Current.Role switch
|
||||||
|
{
|
||||||
|
"buyer" => "Покупатель",
|
||||||
|
"supplier" => "Поставщик",
|
||||||
|
"manager" => "Менеджер",
|
||||||
|
"admin" => "Администратор",
|
||||||
|
_ => Session.Current.Role ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private string? _status;
|
||||||
|
|
||||||
|
public DirectoryViewModel()
|
||||||
|
{
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
if (Session.Current.IsAuthenticated) _ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
var locs = await _api.GetBuyerLocationsAsync();
|
||||||
|
Locations.Clear();
|
||||||
|
foreach (var l in locs) Locations.Add(l);
|
||||||
|
if (Locations.Count == 0) Status = "Грузополучатели не заданы.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Elfisa.Avalonia/ViewModels/InvoicesViewModel.cs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public sealed class InvoiceRow
|
||||||
|
{
|
||||||
|
public string Date { get; init; } = "";
|
||||||
|
public string Number { get; init; } = "";
|
||||||
|
public string Supplier { get; init; } = "";
|
||||||
|
public string Location { get; init; } = "";
|
||||||
|
public int ItemsCount { get; init; }
|
||||||
|
public decimal Sum { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Накладные — реестр приходных документов. Строится из /api/buyer/report
|
||||||
|
/// (после деплоя фикса /api/buyer/orders можно перейти на «родной» источник).
|
||||||
|
/// </summary>
|
||||||
|
public partial class InvoicesViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
|
||||||
|
public ObservableCollection<InvoiceRow> Rows { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private string? _status;
|
||||||
|
[ObservableProperty] private string _totals = "";
|
||||||
|
|
||||||
|
public InvoicesViewModel()
|
||||||
|
{
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
if (Session.Current.IsAuthenticated) _ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
var to = DateTime.Today;
|
||||||
|
var from = to.AddMonths(-24);
|
||||||
|
var lines = await _api.GetBuyerReportAsync(from, to);
|
||||||
|
|
||||||
|
Rows.Clear();
|
||||||
|
foreach (var g in lines.GroupBy(l => l.OrderId)
|
||||||
|
.OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue))
|
||||||
|
{
|
||||||
|
var f = g.First();
|
||||||
|
Rows.Add(new InvoiceRow
|
||||||
|
{
|
||||||
|
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()),
|
||||||
|
Location = f.Location ?? "",
|
||||||
|
ItemsCount = g.Count(),
|
||||||
|
Sum = g.Sum(x => x.Sum)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Totals = $"Накладных: {Rows.Count} Сумма: {Rows.Sum(r => r.Sum):N2}";
|
||||||
|
if (Rows.Count == 0) Status = "Накладных за последние 2 года нет.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>«Загрузить» — скачивание подключённых прайсов с сервера в память.</summary>
|
||||||
|
public partial class LoadPriceViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
|
||||||
|
/// <summary>Прайсы, подключённые покупателю (какие именно скачиваем).</summary>
|
||||||
|
public ObservableCollection<BuyerPriceListDto> PriceLists { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private double _progress; // 0..100
|
||||||
|
[ObservableProperty] private string _progressText = "";
|
||||||
|
[ObservableProperty] private string? _result;
|
||||||
|
[ObservableProperty] private string _priceListsInfo = "Загрузка списка прайсов…";
|
||||||
|
|
||||||
|
public string LastLoaded =>
|
||||||
|
PriceCache.LoadedAt is { } dt
|
||||||
|
? $"Последняя загрузка: {dt:dd.MM.yyyy HH:mm} — {PriceCache.Items.Count} позиций"
|
||||||
|
: "Прайс ещё не загружался в этой сессии.";
|
||||||
|
|
||||||
|
public LoadPriceViewModel()
|
||||||
|
{
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
if (Session.Current.IsAuthenticated)
|
||||||
|
_ = LoadPriceListsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Тянет список подключённых прайсов — чтобы было видно, что именно скачиваем.</summary>
|
||||||
|
private async Task LoadPriceListsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lists = await _api.GetBuyerPriceListsAsync();
|
||||||
|
PriceLists.Clear();
|
||||||
|
foreach (var pl in lists) PriceLists.Add(pl);
|
||||||
|
PriceListsInfo = lists.Count == 0
|
||||||
|
? "Прайсы пока не подключены — обратитесь к менеджеру."
|
||||||
|
: $"Подключено прайсов: {lists.Count}";
|
||||||
|
}
|
||||||
|
catch { PriceListsInfo = "Не удалось получить список подключённых прайсов."; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
Result = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
Progress = 0;
|
||||||
|
ProgressText = "Соединение с сервером…";
|
||||||
|
|
||||||
|
var all = new List<PriceItem>();
|
||||||
|
const int page = 500;
|
||||||
|
int offset = 0, total = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var resp = await _api.GetPriceSummaryAsync(null, limit: page, offset: offset);
|
||||||
|
var batch = resp.Summary ?? new();
|
||||||
|
if (resp.TotalDrugs > 0) total = resp.TotalDrugs;
|
||||||
|
all.AddRange(batch);
|
||||||
|
|
||||||
|
Progress = total > 0 ? Math.Min(100.0, all.Count * 100.0 / total) : 0;
|
||||||
|
ProgressText = total > 0
|
||||||
|
? $"Загружено {all.Count} из {total} позиций…"
|
||||||
|
: $"Загружено {all.Count} позиций…";
|
||||||
|
|
||||||
|
offset += batch.Count;
|
||||||
|
if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 30000)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
PriceCache.Items = all;
|
||||||
|
PriceCache.LoadedAt = DateTime.Now;
|
||||||
|
Progress = 100;
|
||||||
|
ProgressText = "Готово.";
|
||||||
|
|
||||||
|
var suppliers = all.Select(x => x.SupplierName).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().Count();
|
||||||
|
Result = $"Прайс загружен: {all.Count} позиций от {suppliers} поставщик(ов).";
|
||||||
|
OnPropertyChanged(nameof(LastLoaded));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Result = "Ошибка загрузки: " + ex.Message;
|
||||||
|
ProgressText = "";
|
||||||
|
}
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/Elfisa.Avalonia/ViewModels/OrdersViewModel.cs
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public sealed class OrderItemRow
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Code { get; init; } = "";
|
||||||
|
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
|
||||||
|
{
|
||||||
|
public string Date { get; init; } = "";
|
||||||
|
public string Number { get; init; } = "";
|
||||||
|
public string Supplier { get; init; } = "";
|
||||||
|
public string Location { get; init; } = "";
|
||||||
|
public string Status { get; init; } = "";
|
||||||
|
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).
|
||||||
|
/// Черновик можно отправить (создать реальный заказ) или удалить.
|
||||||
|
/// </summary>
|
||||||
|
public partial class OrdersViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
|
||||||
|
public ObservableCollection<OrderSummary> Orders { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private OrderSummary? _selectedOrder;
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private string? _status;
|
||||||
|
|
||||||
|
public OrdersViewModel()
|
||||||
|
{
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
if (Session.Current.IsAuthenticated) _ = LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
Status = null;
|
||||||
|
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);
|
||||||
|
foreach (var g in lines.GroupBy(l => l.OrderId)
|
||||||
|
.OrderByDescending(x => x.First().OrderDate ?? DateTime.MinValue))
|
||||||
|
{
|
||||||
|
var f = g.First();
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
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()),
|
||||||
|
Location = f.Location ?? "",
|
||||||
|
Status = f.Status ?? "",
|
||||||
|
ItemsCount = g.Count(),
|
||||||
|
Sum = g.Sum(x => x.Sum),
|
||||||
|
Items = items
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedOrder = Orders.FirstOrDefault();
|
||||||
|
}
|
||||||
|
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
|
||||||
|
{
|
||||||
|
GlobalSign = d.Number.StartsWith("EX-", StringComparison.OrdinalIgnoreCase) ? d.Number : null,
|
||||||
|
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 = "Черновик удалён.";
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>Заглушка ещё не портированного раздела.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
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;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
private readonly CartService _cart = CartService.Instance;
|
||||||
|
|
||||||
|
// Плоский список предложений + предвычисленные база/МГ (как «Базовое наименование»/«МГ» в 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();
|
||||||
|
public ObservableCollection<BuyerPriceListDto> MyPriceLists { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _search = "";
|
||||||
|
[ObservableProperty] private BuyerPriceListDto? _selectedPriceList;
|
||||||
|
[ObservableProperty] private bool _hasMultiplePriceLists;
|
||||||
|
[ObservableProperty] private string? _selectedBaseName;
|
||||||
|
[ObservableProperty] private string? _selectedMg;
|
||||||
|
[ObservableProperty] private OfferRow? _selectedOffer;
|
||||||
|
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
|
||||||
|
[ObservableProperty] private string _comment = "";
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private string? _status;
|
||||||
|
[ObservableProperty] private int _total;
|
||||||
|
[ObservableProperty] private decimal _cartTotal;
|
||||||
|
|
||||||
|
public PriceListViewModel()
|
||||||
|
{
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
_cart.Changed += Recalc;
|
||||||
|
Recalc();
|
||||||
|
if (Session.Current.IsAuthenticated)
|
||||||
|
{
|
||||||
|
_ = LoadPriceListsAsync(); // грузит «Мои прайсы» → ставит выбор → запускает поиск
|
||||||
|
_ = LoadLocationsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _suppressPriceListSearch;
|
||||||
|
|
||||||
|
/// <summary>Загружает подключённые покупателю прайсы в дропдаун «Мои прайсы».</summary>
|
||||||
|
private async Task LoadPriceListsAsync()
|
||||||
|
{
|
||||||
|
List<BuyerPriceListDto> lists;
|
||||||
|
try { lists = await _api.GetBuyerPriceListsAsync(); }
|
||||||
|
catch { lists = new(); }
|
||||||
|
|
||||||
|
MyPriceLists.Clear();
|
||||||
|
MyPriceLists.Add(new BuyerPriceListDto { PriceListId = null, Name = "Все прайсы" });
|
||||||
|
foreach (var pl in lists) MyPriceLists.Add(pl);
|
||||||
|
HasMultiplePriceLists = lists.Count > 1;
|
||||||
|
|
||||||
|
_suppressPriceListSearch = true;
|
||||||
|
// Один подключён — выбираем его; несколько или ни одного — «Все прайсы».
|
||||||
|
SelectedPriceList = lists.Count == 1 ? MyPriceLists[1] : MyPriceLists[0];
|
||||||
|
_suppressPriceListSearch = false;
|
||||||
|
|
||||||
|
await SearchAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedPriceListChanged(BuyerPriceListDto? value)
|
||||||
|
{
|
||||||
|
if (_suppressPriceListSearch) return;
|
||||||
|
_ = SearchAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
Status = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
var resp = await _api.GetPriceSummaryAsync(Search, limit: 300, priceListId: SelectedPriceList?.PriceListId);
|
||||||
|
RebuildIndex(resp.Summary ?? new());
|
||||||
|
Total = resp.TotalDrugs;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Пересобирает индекс база/МГ и список базовых наименований.</summary>
|
||||||
|
private void RebuildIndex(List<PriceItem> items)
|
||||||
|
{
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveDraftAsync()
|
||||||
|
{
|
||||||
|
if (_cart.Count == 0) { Status = "Заказ пуст — добавьте позиции."; return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
// Резервируем настоящий номер заказа EX-####### (как в WinForms)
|
||||||
|
var sign = await _api.NextGlobalSignAsync();
|
||||||
|
|
||||||
|
var draft = new DraftOrder
|
||||||
|
{
|
||||||
|
Number = string.IsNullOrWhiteSpace(sign) ? $"ЧРН-{DateTime.Now:HHmmss}" : sign!,
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
DraftStore.Add(draft);
|
||||||
|
_cart.Clear();
|
||||||
|
Comment = "";
|
||||||
|
Status = $"Заказ сохранён: {draft.Number} → раздел «Заказы» (не отправлен).";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = "Ошибка сохранения: " + ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Строка отчёта: ячейки как строки, доступ по индексу для DataGrid.</summary>
|
||||||
|
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<BuyerReportLineDto> _lines = new();
|
||||||
|
|
||||||
|
/// <summary>Сигнал View пересобрать колонки DataGrid.</summary>
|
||||||
|
public event Action? ColumnsChanged;
|
||||||
|
|
||||||
|
public ObservableCollection<ReportColumn> Columns { get; } = new();
|
||||||
|
public ObservableCollection<ReportRow> 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<ReportColumn> cols, List<string[]> rows) Aggregate(List<BuyerReportLineDto> 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<ReportColumn>
|
||||||
|
{
|
||||||
|
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<ReportColumn>
|
||||||
|
{
|
||||||
|
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<ReportColumn>
|
||||||
|
{
|
||||||
|
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<ReportColumn>
|
||||||
|
{
|
||||||
|
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 { /* открыть не удалось — путь показан в статусе */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/Elfisa.Avalonia/ViewModels/RootViewModel.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>Корневой VM: переключает экран входа и основной шелл.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/Elfisa.Avalonia/ViewModels/SendOrderViewModel.cs
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отправить: поиск по прайсу → корзина (общая с Прайс-листом) → создание заказа.
|
||||||
|
/// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SendOrderViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
private readonly CartService _cart = CartService.Instance;
|
||||||
|
|
||||||
|
public ObservableCollection<PriceItem> Results { get; } = new();
|
||||||
|
public ObservableCollection<CartItem> Cart => _cart.Items;
|
||||||
|
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _search = "";
|
||||||
|
[ObservableProperty] private PriceItem? _selectedResult;
|
||||||
|
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
|
||||||
|
[ObservableProperty] private string _comment = "";
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private string? _status;
|
||||||
|
[ObservableProperty] private decimal _cartTotal;
|
||||||
|
|
||||||
|
public SendOrderViewModel()
|
||||||
|
{
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
_cart.Changed += Recalc;
|
||||||
|
Recalc();
|
||||||
|
if (Session.Current.IsAuthenticated) _ = InitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var locs = await _api.GetBuyerLocationsAsync();
|
||||||
|
Locations.Clear();
|
||||||
|
foreach (var l in locs) Locations.Add(l);
|
||||||
|
SelectedLocation = Locations.FirstOrDefault(l => l.IsDefault) ?? Locations.FirstOrDefault();
|
||||||
|
await SearchAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SearchAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Busy = true; Status = null;
|
||||||
|
var resp = await _api.GetPriceSummaryAsync(Search, limit: 200);
|
||||||
|
Results.Clear();
|
||||||
|
foreach (var it in resp.Summary ?? new()) Results.Add(it);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddToCart()
|
||||||
|
{
|
||||||
|
if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; }
|
||||||
|
_cart.Add(SelectedResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
|
||||||
|
|
||||||
|
private void Recalc() => CartTotal = _cart.Total;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SendAsync()
|
||||||
|
{
|
||||||
|
Status = null;
|
||||||
|
if (_cart.Count == 0) { Status = "Корзина пуста."; return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Busy = true;
|
||||||
|
var req = new BuyerOrderCreateRequest
|
||||||
|
{
|
||||||
|
LocationId = SelectedLocation?.BuyerLocationId,
|
||||||
|
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
|
||||||
|
Items = _cart.Items.Select(c => new BuyerOrderItemReq
|
||||||
|
{
|
||||||
|
SupplierPriceId = c.Source.SupplierPriceId ?? "",
|
||||||
|
Qty = (double)c.Qty,
|
||||||
|
ItemName = c.Source.DrugName,
|
||||||
|
Barcode = c.Source.Barcode
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
var resp = await _api.CreateOrderAsync(req);
|
||||||
|
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} создан: {resp.ItemsCount} поз., {resp.TotalAmount:N2}.";
|
||||||
|
_cart.Clear();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Elfisa.Avalonia/ViewModels/SettingsViewModel.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private string _apiBaseUrl = "";
|
||||||
|
public string[] Themes { get; } = { "Светлая", "Тёмная" };
|
||||||
|
[ObservableProperty] private int _themeIndex;
|
||||||
|
|
||||||
|
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
|
||||||
|
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
|
||||||
|
|
||||||
|
public SettingsViewModel()
|
||||||
|
{
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
ApiBaseUrl = SettingsStore.Current.ApiBaseUrl ?? AppConfig.DefaultApiBaseUrl;
|
||||||
|
ThemeIndex = SettingsStore.Current.Theme == "Dark" ? 1 : 0;
|
||||||
|
if (Session.Current.IsAuthenticated) _ = LoadLocationsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { /* локации не критичны для настроек */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
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; } // глиф Segoe MDL2 Assets
|
||||||
|
public Func<ViewModelBase> Factory { get; }
|
||||||
|
public NavItem(string title, string icon, Func<ViewModelBase> factory)
|
||||||
|
{
|
||||||
|
Title = title; Icon = icon; Factory = factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Открытая вкладка-документ.</summary>
|
||||||
|
public sealed class DocumentTab
|
||||||
|
{
|
||||||
|
public string Title { get; }
|
||||||
|
public string Key { get; }
|
||||||
|
public ViewModelBase Content { get; }
|
||||||
|
public DocumentTab(string title, string key, ViewModelBase content)
|
||||||
|
{
|
||||||
|
Title = title; Key = key; Content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ShellViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly Action _logout;
|
||||||
|
private readonly ApiClient _api = new();
|
||||||
|
private bool _sync;
|
||||||
|
|
||||||
|
public ObservableCollection<NavItem> NavItems { get; }
|
||||||
|
public ObservableCollection<DocumentTab> Tabs { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private NavItem? _selectedNav;
|
||||||
|
[ObservableProperty] private DocumentTab? _activeTab;
|
||||||
|
|
||||||
|
public string UserName => Session.Current.Username ?? "";
|
||||||
|
public string RoleLabel => Session.Current.Role switch
|
||||||
|
{
|
||||||
|
"buyer" => "Покупатель",
|
||||||
|
"supplier" => "Поставщик",
|
||||||
|
"manager" => "Менеджер",
|
||||||
|
"admin" => "Администратор",
|
||||||
|
_ => Session.Current.Role ?? ""
|
||||||
|
};
|
||||||
|
|
||||||
|
[ObservableProperty] private string _pharmacyAddress = "";
|
||||||
|
|
||||||
|
// Segoe MDL2 Assets — те же глифы, что в WinForms-версии
|
||||||
|
private const string IcoPrice = "";
|
||||||
|
private const string IcoOrders = "";
|
||||||
|
private const string IcoInvoices = "";
|
||||||
|
private const string IcoReports = "";
|
||||||
|
private const string IcoUpload = "";
|
||||||
|
private const string IcoSend = "";
|
||||||
|
private const string IcoRefusals = "";
|
||||||
|
private const string IcoDirectory = "";
|
||||||
|
|
||||||
|
public ShellViewModel(Action logout)
|
||||||
|
{
|
||||||
|
_logout = logout;
|
||||||
|
_api.SetToken(Session.Current.Token);
|
||||||
|
|
||||||
|
NavItems = new ObservableCollection<NavItem>
|
||||||
|
{
|
||||||
|
new("Прайс-лист", IcoPrice, () => new PriceListViewModel()),
|
||||||
|
new("Заказы", IcoOrders, () => new OrdersViewModel()),
|
||||||
|
new("Накладные", IcoInvoices, () => new InvoicesViewModel()),
|
||||||
|
new("Отчёты", IcoReports, () => new ReportsViewModel()),
|
||||||
|
new("Загрузить", IcoUpload, () => new LoadPriceViewModel()),
|
||||||
|
new("Отправить", IcoSend, () => new SendOrderViewModel()),
|
||||||
|
new("Отказы", IcoRefusals, () => new PlaceholderPageViewModel("Отказы",
|
||||||
|
"Отказные позиции по заказам. Нужен серверный эндпоинт (RefuseItemsCount сейчас только в локальной БД WinForms).")),
|
||||||
|
new("Справочник", IcoDirectory, () => new DirectoryViewModel()),
|
||||||
|
};
|
||||||
|
|
||||||
|
SelectedNav = NavItems.First(n => n.Title == "Отчёты");
|
||||||
|
if (Session.Current.IsAuthenticated) _ = LoadPharmacyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPharmacyAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var locs = await _api.GetBuyerLocationsAsync();
|
||||||
|
var def = locs.FirstOrDefault(l => l.IsDefault) ?? locs.FirstOrDefault();
|
||||||
|
PharmacyAddress = def?.Address ?? "";
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedNavChanged(NavItem? value)
|
||||||
|
{
|
||||||
|
if (_sync || value is null) return;
|
||||||
|
OpenOrActivate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenOrActivate(NavItem nav)
|
||||||
|
{
|
||||||
|
var existing = Tabs.FirstOrDefault(t => t.Key == nav.Title);
|
||||||
|
if (existing is not null) { ActiveTab = existing; return; }
|
||||||
|
var tab = new DocumentTab(nav.Title, nav.Title, nav.Factory());
|
||||||
|
Tabs.Add(tab);
|
||||||
|
ActiveTab = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnActiveTabChanged(DocumentTab? value)
|
||||||
|
{
|
||||||
|
if (value is null) return;
|
||||||
|
_sync = true;
|
||||||
|
SelectedNav = NavItems.FirstOrDefault(n => n.Title == value.Key);
|
||||||
|
_sync = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseTab(DocumentTab? tab)
|
||||||
|
{
|
||||||
|
if (tab is null) return;
|
||||||
|
int idx = Tabs.IndexOf(tab);
|
||||||
|
bool wasActive = ReferenceEquals(ActiveTab, tab);
|
||||||
|
Tabs.Remove(tab);
|
||||||
|
if (wasActive)
|
||||||
|
ActiveTab = Tabs.Count == 0 ? null : Tabs[Math.Min(idx, Tabs.Count - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void Logout() => _logout();
|
||||||
|
}
|
||||||
7
src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
56
src/Elfisa.Avalonia/Views/DirectoryView.axaml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:core="using:Elfisa.Core"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.DirectoryView"
|
||||||
|
x:DataType="vm:DirectoryViewModel">
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Margin="24" Spacing="14">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="Справочник" Classes="h1"/>
|
||||||
|
<TextBlock Text="Профиль и грузополучатели" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Профиль -->
|
||||||
|
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="Учётная запись" Classes="h2"/>
|
||||||
|
<Grid ColumnDefinitions="160,*" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Логин" Classes="muted"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding UserName}" Foreground="{DynamicResource Text}"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Роль" Classes="muted" Margin="0,6,0,0"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding RoleLabel}" Foreground="{DynamicResource Text}" Margin="0,6,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Грузополучатели -->
|
||||||
|
<Border Classes="card" MaxWidth="720" HorizontalAlignment="Left">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="Грузополучатели (адреса)" Classes="h2"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Locations}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="core:BuyerLocationDto">
|
||||||
|
<Border Padding="12" Margin="0,0,0,8" CornerRadius="9"
|
||||||
|
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
||||||
|
<StackPanel Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Address}" Foreground="{DynamicResource Text}" TextWrapping="Wrap"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="{Binding RegionName}" Classes="muted"/>
|
||||||
|
<Border Background="{DynamicResource AccentSoft}" CornerRadius="5" Padding="6,1"
|
||||||
|
IsVisible="{Binding IsDefault}">
|
||||||
|
<TextBlock Text="по умолчанию" FontSize="11" Foreground="{DynamicResource AccentPressed}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="{Binding Status}" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
9
src/Elfisa.Avalonia/Views/DirectoryView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class DirectoryView : UserControl
|
||||||
|
{
|
||||||
|
public DirectoryView() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
39
src/Elfisa.Avalonia/Views/InvoicesView.axaml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.InvoicesView"
|
||||||
|
x:DataType="vm:InvoicesViewModel">
|
||||||
|
|
||||||
|
<DockPanel Margin="24">
|
||||||
|
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
|
||||||
|
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Накладные" Classes="h1"/>
|
||||||
|
<TextBlock Text="Реестр приходных документов" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding Totals}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<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"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="card" Padding="0" ClipToBounds="True">
|
||||||
|
<DataGrid ItemsSource="{Binding Rows}" x:CompileBindings="False"
|
||||||
|
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
|
||||||
|
CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Дата" Width="112" Binding="{Binding Date}"/>
|
||||||
|
<DataGridTextColumn Header="Номер" Width="150" Binding="{Binding Number}"/>
|
||||||
|
<DataGridTextColumn Header="Поставщик" Width="160" Binding="{Binding Supplier}"/>
|
||||||
|
<DataGridTextColumn Header="Аптека" Width="*" Binding="{Binding Location}"/>
|
||||||
|
<DataGridTextColumn Header="Позиций" Width="90" Binding="{Binding ItemsCount}" CellStyleClasses="num"/>
|
||||||
|
<DataGridTextColumn Header="Сумма" Width="132" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
9
src/Elfisa.Avalonia/Views/InvoicesView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class InvoicesView : UserControl
|
||||||
|
{
|
||||||
|
public InvoicesView() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
61
src/Elfisa.Avalonia/Views/LoadPriceView.axaml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
xmlns:core="using:Elfisa.Core"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.LoadPriceView"
|
||||||
|
x:DataType="vm:LoadPriceViewModel">
|
||||||
|
|
||||||
|
<Grid Margin="24" RowDefinitions="Auto,*">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="2">
|
||||||
|
<TextBlock Text="Загрузить" Classes="h1"/>
|
||||||
|
<TextBlock Text="Скачать актуальный прайс с сервера" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Grid.Row="1" Classes="card" MaxWidth="560" Padding="30"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Spacing="18" HorizontalAlignment="Center">
|
||||||
|
<Border Width="60" Height="60" CornerRadius="30" Background="{DynamicResource AccentSoft}">
|
||||||
|
<TextBlock Text="📥" FontSize="26" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding LastLoaded}" Classes="muted" HorizontalAlignment="Center" TextAlignment="Center"/>
|
||||||
|
|
||||||
|
<!-- Подключённые прайсы — что именно скачиваем -->
|
||||||
|
<Border Background="{DynamicResource RowAlt}" CornerRadius="10" Padding="16,12" Width="380"
|
||||||
|
BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="{Binding PriceListsInfo}" FontWeight="SemiBold" Foreground="{DynamicResource Text}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding PriceLists}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="core:BuyerPriceListDto">
|
||||||
|
<Grid ColumnDefinitions="Auto,*" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Text="• " Foreground="{DynamicResource Accent}" FontWeight="Bold"/>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="1">
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SupplierName}" Classes="muted" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button Classes="primary" Content="Загрузить прайс" HorizontalAlignment="Center"
|
||||||
|
Padding="28,12" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding Busy}">
|
||||||
|
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="8" Width="360"/>
|
||||||
|
<TextBlock Text="{Binding ProgressText}" Classes="muted" HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Background="{DynamicResource AccentSoft}" CornerRadius="8" Padding="14,10"
|
||||||
|
IsVisible="{Binding Result, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Text="{Binding Result}" Foreground="{DynamicResource AccentPressed}"
|
||||||
|
TextWrapping="Wrap" TextAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
9
src/Elfisa.Avalonia/Views/LoadPriceView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class LoadPriceView : UserControl
|
||||||
|
{
|
||||||
|
public LoadPriceView() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
39
src/Elfisa.Avalonia/Views/LoginView.axaml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.LoginView"
|
||||||
|
x:DataType="vm:LoginViewModel">
|
||||||
|
|
||||||
|
<Grid Background="{DynamicResource Bg}">
|
||||||
|
<Border Classes="card" Width="390" Padding="30"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<StackPanel Spacing="2" Margin="0,0,0,6">
|
||||||
|
<TextBlock Text="ЭльФиСА" FontSize="28" FontWeight="Bold"
|
||||||
|
Foreground="{DynamicResource Accent}" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Электронная Фармация" Classes="muted" HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Логин" Classes="label"/>
|
||||||
|
<TextBox Text="{Binding Username}" Watermark="e-mail или логин"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Пароль" Classes="label"/>
|
||||||
|
<TextBox Text="{Binding Password}" PasswordChar="●" Watermark="пароль"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Background="#1FDC2626" CornerRadius="6" Padding="10,7"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Text="{Binding Error}" Foreground="#DC2626" TextWrapping="Wrap" FontSize="12.5"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button Classes="primary" Content="Войти" IsDefault="True"
|
||||||
|
HorizontalAlignment="Stretch" Margin="0,4,0,0"
|
||||||
|
Command="{Binding LoginCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
9
src/Elfisa.Avalonia/Views/LoginView.axaml.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
16
src/Elfisa.Avalonia/Views/MainWindow.axaml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="740"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.MainWindow"
|
||||||
|
x:DataType="vm:RootViewModel"
|
||||||
|
Icon="/Assets/avalonia-logo.ico"
|
||||||
|
Width="1200" Height="740" MinWidth="920" MinHeight="600"
|
||||||
|
Background="{DynamicResource Bg}"
|
||||||
|
Title="ЭльФиСА">
|
||||||
|
|
||||||
|
<ContentControl Content="{Binding Current}"/>
|
||||||
|
|
||||||
|
</Window>
|
||||||
11
src/Elfisa.Avalonia/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/Elfisa.Avalonia/Views/OrdersView.axaml
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.OrdersView"
|
||||||
|
x:DataType="vm:OrdersViewModel">
|
||||||
|
|
||||||
|
<Grid Margin="24" RowDefinitions="Auto,*">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,14">
|
||||||
|
<TextBlock Text="Заказы" Classes="h1"/>
|
||||||
|
<TextBlock Text="История заказов и их состав" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,16,430">
|
||||||
|
|
||||||
|
<!-- Список заказов -->
|
||||||
|
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
|
||||||
|
<DataGrid ItemsSource="{Binding Orders}" SelectedItem="{Binding SelectedOrder}"
|
||||||
|
x:CompileBindings="False"
|
||||||
|
AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column"
|
||||||
|
CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Дата" Width="112" Binding="{Binding Date}"/>
|
||||||
|
<DataGridTextColumn Header="Номер" Width="140" Binding="{Binding Number}"/>
|
||||||
|
<DataGridTextColumn Header="Поставщик" Width="*" Binding="{Binding Supplier}"/>
|
||||||
|
<DataGridTextColumn Header="Позиций" Width="90" Binding="{Binding ItemsCount}" CellStyleClasses="num"/>
|
||||||
|
<DataGridTextColumn Header="Сумма" Width="132" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||||
|
<DataGridTextColumn Header="Статус" Width="95" Binding="{Binding Status}"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Карточка заказа -->
|
||||||
|
<Border Grid.Column="2" Classes="card" Padding="18">
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Top" Spacing="4" Margin="0,0,0,14">
|
||||||
|
<TextBlock Text="{Binding SelectedOrder.Number}" Classes="h2"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="{Binding SelectedOrder.Date}" Classes="muted"/>
|
||||||
|
<TextBlock Text="{Binding SelectedOrder.Status}" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding SelectedOrder.Supplier}" Classes="muted" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SelectedOrder.Location}" Classes="muted" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Товар" Width="*" Binding="{Binding Name}"/>
|
||||||
|
<DataGridTextColumn Header="Кол-во" Width="78" Binding="{Binding Qty, StringFormat='{}{0:N0}'}" CellStyleClasses="num"/>
|
||||||
|
<DataGridTextColumn Header="Сумма" Width="110" Binding="{Binding Sum, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Статус -->
|
||||||
|
<Border Grid.Row="1" VerticalAlignment="Bottom" HorizontalAlignment="Left" Margin="4"
|
||||||
|
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"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
9
src/Elfisa.Avalonia/Views/OrdersView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class OrdersView : UserControl
|
||||||
|
{
|
||||||
|
public OrdersView() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
24
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.PlaceholderPageView"
|
||||||
|
x:DataType="vm:PlaceholderPageViewModel">
|
||||||
|
|
||||||
|
<Grid Margin="28" RowDefinitions="Auto,*">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Title}" Classes="h1"/>
|
||||||
|
<TextBlock Text="{Binding Description}" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Grid.Row="1" Classes="card" MaxWidth="480" Padding="34"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Spacing="14" HorizontalAlignment="Center">
|
||||||
|
<Border Width="56" Height="56" CornerRadius="28" Background="{DynamicResource AccentSoft}">
|
||||||
|
<TextBlock Text="⏳" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Скоро" Classes="h2" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Note}" Classes="muted" TextWrapping="Wrap" TextAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
9
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
136
src/Elfisa.Avalonia/Views/PriceListView.axaml
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.PriceListView"
|
||||||
|
x:DataType="vm:PriceListViewModel">
|
||||||
|
|
||||||
|
<DockPanel Margin="24">
|
||||||
|
<Grid DockPanel.Dock="Top" Margin="0,0,0,14" ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Прайс-лист" Classes="h1"/>
|
||||||
|
<TextBlock Text="Каталог позиций — собери заказ и сохрани" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Мои прайсы: видно, по какому прайсу заказываем; если подключено несколько — можно выбрать -->
|
||||||
|
<Border Grid.Column="1" VerticalAlignment="Center" Padding="12,8" CornerRadius="10"
|
||||||
|
Background="{DynamicResource Panel}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Мои прайсы" Classes="label" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox ItemsSource="{Binding MyPriceLists}" SelectedItem="{Binding SelectedPriceList}"
|
||||||
|
MinWidth="240" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<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}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,16,430">
|
||||||
|
|
||||||
|
<!-- Каталог -->
|
||||||
|
<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" 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>
|
||||||
|
<!-- Дрилдаун как в старой программе: Название → МГ → Предложения -->
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
58
src/Elfisa.Avalonia/Views/PriceListView.axaml.cs
Normal file
@ -0,0 +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
|
||||||
|
{
|
||||||
|
// Строка, в которую сейчас набирают количество с клавиатуры (для многозначного ввода).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Elfisa.Avalonia/Views/ReportsView.axaml
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.ReportsView"
|
||||||
|
x:DataType="vm:ReportsViewModel">
|
||||||
|
|
||||||
|
<DockPanel Margin="24">
|
||||||
|
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
|
||||||
|
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Отчёты" Classes="h1"/>
|
||||||
|
<TextBlock Text="Данные только по вашим заказам" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding Totals}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Панель управления -->
|
||||||
|
<Border DockPanel.Dock="Top" Classes="card" Margin="0,0,0,12" Padding="16">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="С" Classes="label" Margin="0,0,8,6"/>
|
||||||
|
<DatePicker SelectedDate="{Binding DateFrom}" Margin="0,0,10,6"/>
|
||||||
|
<TextBlock Text="по" Classes="label" Margin="0,0,8,6"/>
|
||||||
|
<DatePicker SelectedDate="{Binding DateTo}" Margin="0,0,10,6"/>
|
||||||
|
<ComboBox ItemsSource="{Binding ReportTypes}" SelectedIndex="{Binding SelectedReportIndex}" Width="170" Margin="0,0,10,6"/>
|
||||||
|
<Button Classes="primary" Content="Сформировать" Command="{Binding BuildCommand}" IsEnabled="{Binding !Busy}" Margin="0,0,0,6"/>
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<Border Height="1" Background="{DynamicResource Border}"/>
|
||||||
|
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="Запрос ИИ" Classes="label" Margin="0,0,8,6"/>
|
||||||
|
<TextBox Text="{Binding AiPrompt}" Width="420" Margin="0,0,10,6"
|
||||||
|
Watermark="напр.: «закупки по поставщикам за июль», «заказы за всё время с товарами»"/>
|
||||||
|
<ComboBox ItemsSource="{Binding AiFormats}" SelectedIndex="{Binding SelectedAiFormat}" Width="100" Margin="0,0,10,6"/>
|
||||||
|
<Button Classes="primary" Content="Сформировать ИИ" Command="{Binding GenerateAiCommand}" IsEnabled="{Binding !AiBusy}" Margin="0,0,0,6"/>
|
||||||
|
</WrapPanel>
|
||||||
|
</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}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Таблица -->
|
||||||
|
<Border Classes="card" Padding="0" ClipToBounds="True">
|
||||||
|
<DataGrid x:Name="Grid" ItemsSource="{Binding Rows}"
|
||||||
|
AutoGenerateColumns="False" IsReadOnly="True"
|
||||||
|
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False"/>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
60
src/Elfisa.Avalonia/Views/ReportsView.axaml.cs
Normal file
@ -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<ReportRow>((row, _) => new TextBlock
|
||||||
|
{
|
||||||
|
Text = row?[idx] ?? "",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextAlignment = c.Numeric ? TextAlignment.Right : TextAlignment.Left,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Elfisa.Avalonia/Views/SendOrderView.axaml
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.SendOrderView"
|
||||||
|
x:DataType="vm:SendOrderViewModel">
|
||||||
|
|
||||||
|
<DockPanel Margin="24">
|
||||||
|
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
|
||||||
|
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Отправить заказ" Classes="h1"/>
|
||||||
|
<TextBlock Text="Найдите позиции в прайсе, соберите корзину и отправьте поставщику" Classes="muted"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6"
|
||||||
|
Background="{DynamicResource AccentSoft}"
|
||||||
|
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,16,440">
|
||||||
|
|
||||||
|
<!-- Прайс: поиск + результаты -->
|
||||||
|
<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="поиск по наименованию…"/>
|
||||||
|
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button DockPanel.Dock="Bottom" Classes="primary" Content="Добавить в заказ ▸"
|
||||||
|
Margin="14" HorizontalAlignment="Left"
|
||||||
|
Command="{Binding AddToCartCommand}"/>
|
||||||
|
<DataGrid ItemsSource="{Binding Results}" SelectedItem="{Binding SelectedResult}"
|
||||||
|
x:CompileBindings="False" AutoGenerateColumns="False" IsReadOnly="True"
|
||||||
|
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
|
||||||
|
<DataGridTextColumn Header="Поставщик" Width="120" Binding="{Binding SupplierName}"/>
|
||||||
|
<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 SendCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
</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>
|
||||||
9
src/Elfisa.Avalonia/Views/SendOrderView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class SendOrderView : UserControl
|
||||||
|
{
|
||||||
|
public SendOrderView() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
34
src/Elfisa.Avalonia/Views/SettingsWindow.axaml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.SettingsWindow"
|
||||||
|
x:DataType="vm:SettingsViewModel"
|
||||||
|
Width="460" Height="360" CanResize="False"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource Bg}"
|
||||||
|
Title="Настройки">
|
||||||
|
|
||||||
|
<StackPanel Margin="24" Spacing="16">
|
||||||
|
<TextBlock Text="Настройки" Classes="h1"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Адрес сервера (API)" Classes="label"/>
|
||||||
|
<TextBox Text="{Binding ApiBaseUrl}" Watermark="https://24pharmdata.ru"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Тема" Classes="label"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Themes}" SelectedIndex="{Binding ThemeIndex}" HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Грузополучатель по умолчанию" Classes="label"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}" HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,8,0,0">
|
||||||
|
<Button Content="Отмена" Click="OnCancel" Padding="18,9"/>
|
||||||
|
<Button Classes="primary" Content="Сохранить" Click="OnSave"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
31
src/Elfisa.Avalonia/Views/SettingsWindow.axaml.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using Elfisa.Avalonia.ViewModels;
|
||||||
|
using Elfisa.Core;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class SettingsWindow : Window
|
||||||
|
{
|
||||||
|
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
private void OnSave(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is SettingsViewModel vm)
|
||||||
|
{
|
||||||
|
SettingsStore.Current.ApiBaseUrl = string.IsNullOrWhiteSpace(vm.ApiBaseUrl) ? null : vm.ApiBaseUrl.Trim().TrimEnd('/');
|
||||||
|
SettingsStore.Current.Theme = vm.ThemeIndex == 1 ? "Dark" : "Light";
|
||||||
|
SettingsStore.Current.DefaultLocationId = vm.SelectedLocation?.BuyerLocationId;
|
||||||
|
SettingsStore.Save();
|
||||||
|
|
||||||
|
if (Application.Current is { } app)
|
||||||
|
app.RequestedThemeVariant = vm.ThemeIndex == 1 ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||||
|
}
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close();
|
||||||
|
}
|
||||||
95
src/Elfisa.Avalonia/Views/ShellView.axaml
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
|
x:Class="Elfisa.Avalonia.Views.ShellView"
|
||||||
|
x:DataType="vm:ShellViewModel">
|
||||||
|
|
||||||
|
<DockPanel>
|
||||||
|
|
||||||
|
<!-- Верхняя панель навигации -->
|
||||||
|
<Border DockPanel.Dock="Top" Background="{DynamicResource Sidebar}" Height="82">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="18,0">
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center" Margin="0,0,14,0">
|
||||||
|
<TextBlock Text="ЭльФиСА" FontSize="18" FontWeight="Bold" Foreground="White"/>
|
||||||
|
<TextBlock Text="Электронная Фармация" Foreground="{DynamicResource SidebarText}" FontSize="10"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ListBox Grid.Column="1" Classes="topnav" VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||||
|
ItemsSource="{Binding NavItems}" SelectedItem="{Binding SelectedNav}">
|
||||||
|
<ListBox.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListBox.ItemsPanel>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:NavItem">
|
||||||
|
<StackPanel Orientation="Vertical" Width="66" Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Icon}" FontFamily="Segoe MDL2 Assets" FontSize="18"
|
||||||
|
Foreground="White" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Title}" Foreground="{DynamicResource SidebarText}"
|
||||||
|
FontSize="11" HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center" Margin="8,0,0,0">
|
||||||
|
<StackPanel VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding UserName}" Foreground="White" FontSize="12" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding RoleLabel}" Foreground="{DynamicResource SidebarText}"
|
||||||
|
FontSize="10.5" HorizontalAlignment="Right"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Content="Выйти" Command="{Binding LogoutCommand}"
|
||||||
|
Background="Transparent" Foreground="White" BorderBrush="#55FFFFFF"
|
||||||
|
BorderThickness="1" CornerRadius="8" Padding="16,9" Cursor="Hand"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Строка вкладок-документов -->
|
||||||
|
<Border DockPanel.Dock="Top" Background="{DynamicResource Surface}"
|
||||||
|
BorderBrush="{DynamicResource Border}" BorderThickness="0,0,0,1" MinHeight="44" Padding="10,0">
|
||||||
|
<ListBox Classes="tabs" VerticalAlignment="Center" x:CompileBindings="False"
|
||||||
|
ItemsSource="{Binding Tabs}" SelectedItem="{Binding ActiveTab}">
|
||||||
|
<ListBox.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListBox.ItemsPanel>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:DocumentTab">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" FontSize="13"/>
|
||||||
|
<Button Content="✕" FontSize="11" Width="20" Height="20" Padding="0"
|
||||||
|
Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||||
|
Foreground="{DynamicResource Muted}"
|
||||||
|
Command="{Binding $parent[ListBox].DataContext.CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Нижняя строка состояния -->
|
||||||
|
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Surface}"
|
||||||
|
BorderBrush="{DynamicResource Border}" BorderThickness="0,1,0,0" Height="32" Padding="16,0">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Пользователь:" Classes="muted" FontSize="12"/>
|
||||||
|
<TextBlock Text="{Binding UserName}" FontSize="12" Foreground="{DynamicResource Text}"/>
|
||||||
|
<TextBlock Text=" | Аптека:" Classes="muted" FontSize="12"/>
|
||||||
|
<TextBlock Text="{Binding PharmacyAddress}" FontSize="12" Foreground="{DynamicResource Text}" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="Настройки" Click="OnSettingsClick"
|
||||||
|
Background="Transparent" Foreground="{DynamicResource Accent}" BorderThickness="0"
|
||||||
|
Padding="10,4" FontSize="12.5" Cursor="Hand"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Контент активной вкладки -->
|
||||||
|
<ContentControl Content="{Binding ActiveTab.Content}"/>
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
21
src/Elfisa.Avalonia/Views/ShellView.axaml.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class ShellView : UserControl
|
||||||
|
{
|
||||||
|
public ShellView() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var win = new SettingsWindow { DataContext = new SettingsViewModel() };
|
||||||
|
var owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||||
|
if (owner is not null) await win.ShowDialog(owner);
|
||||||
|
else win.Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Elfisa.Avalonia/app.manifest
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<!-- This manifest is used on Windows only.
|
||||||
|
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||||
|
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="Elfisa.Avalonia.Desktop"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on
|
||||||
|
and is designed to work with. Uncomment the appropriate elements
|
||||||
|
and Windows will automatically select the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
195
src/Elfisa.Core/ApiClient.cs
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Elfisa.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Клиент платформы. Портирован из WinForms-версии, оставлены методы,
|
||||||
|
/// нужные новому Avalonia-клиенту: логин, отчёт покупателя, ИИ-отчёт.
|
||||||
|
/// Остальные эндпоинты добавляются по мере портирования экранов.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary>
|
||||||
|
public async Task<LoginResponse> 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<LoginResponse>(text, Json);
|
||||||
|
if (login is null || string.IsNullOrWhiteSpace(login.Token))
|
||||||
|
throw new ApiException("Сервер вернул пустой токен.");
|
||||||
|
_token = login.Token;
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отчёт покупателя за период (скоуп по JWT на сервере — только свои заказы).</summary>
|
||||||
|
public async Task<List<BuyerReportLineDto>> 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<BuyerReportResponse>(text, Json);
|
||||||
|
return resp?.Lines ?? new List<BuyerReportLineDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Свободный запрос к ИИ -> готовый Word/Excel (сервис на Mac mini через /ai).</summary>
|
||||||
|
public async Task<AiReportResult> 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('"') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сводный прайс (то же, что грузит WinForms-десктоп). Поиск по q (от 2 символов).</summary>
|
||||||
|
public async Task<PriceSummaryResponse> GetPriceSummaryAsync(string? q = null, int limit = 200, int offset = 0, string? priceListId = null)
|
||||||
|
{
|
||||||
|
EnsureAuth();
|
||||||
|
var path = $"/api/supplier-prices/summary?limit={limit}&offset={offset}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(q) && q.Trim().Length >= 2)
|
||||||
|
path += "&q=" + Uri.EscapeDataString(q.Trim());
|
||||||
|
if (!string.IsNullOrWhiteSpace(priceListId))
|
||||||
|
path += "&price_list_id=" + Uri.EscapeDataString(priceListId);
|
||||||
|
var text = await AuthorizedGetAsync(path, "загрузку прайса").ConfigureAwait(false);
|
||||||
|
return JsonSerializer.Deserialize<PriceSummaryResponse>(text, Json) ?? new PriceSummaryResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Прайс-листы, подключённые текущему покупателю (для выбора «Мои прайсы»).</summary>
|
||||||
|
public async Task<List<BuyerPriceListDto>> GetBuyerPriceListsAsync()
|
||||||
|
{
|
||||||
|
EnsureAuth();
|
||||||
|
var text = await AuthorizedGetAsync("/api/buyer/price-lists", "загрузку прайсов").ConfigureAwait(false);
|
||||||
|
try { return JsonSerializer.Deserialize<List<BuyerPriceListDto>>(text, Json) ?? new(); }
|
||||||
|
catch { return new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Создаёт заказ (Placed). ВНИМАНИЕ: создаёт реальный заказ у поставщика.</summary>
|
||||||
|
public async Task<BuyerOrderResponse> CreateOrderAsync(BuyerOrderCreateRequest request)
|
||||||
|
{
|
||||||
|
EnsureAuth();
|
||||||
|
if (request.Items.Count == 0) throw new ApiException("Корзина пуста.");
|
||||||
|
var body = JsonSerializer.Serialize(request, Json);
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/api/buyer/orders"))
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||||||
|
HttpResponseMessage resp;
|
||||||
|
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||||
|
catch (Exception ex) { throw new ApiException($"Не удалось отправить заказ: {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}).");
|
||||||
|
return JsonSerializer.Deserialize<BuyerOrderResponse>(text, Json) ?? new BuyerOrderResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Резервирует следующий номер заказа EX-####### (как в WinForms до создания заказа).</summary>
|
||||||
|
public async Task<string?> NextGlobalSignAsync()
|
||||||
|
{
|
||||||
|
EnsureAuth();
|
||||||
|
var text = await AuthorizedGetAsync("/api/buyer/global-sign/next", "резервирование номера").ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
return doc.RootElement.TryGetProperty("global_sign", out var v) ? v.GetString() : null;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Адреса-грузополучатели покупателя.</summary>
|
||||||
|
public async Task<List<BuyerLocationDto>> GetBuyerLocationsAsync()
|
||||||
|
{
|
||||||
|
EnsureAuth();
|
||||||
|
var text = await AuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей").ConfigureAwait(false);
|
||||||
|
try { return JsonSerializer.Deserialize<List<BuyerLocationDto>>(text, Json) ?? new(); }
|
||||||
|
catch { return new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> 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<ErrorResponse>(body, Json)?.Error; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApiException : Exception
|
||||||
|
{
|
||||||
|
public ApiException(string message, Exception? inner = null) : base(message, inner) { }
|
||||||
|
}
|
||||||
35
src/Elfisa.Core/AppConfig.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
namespace Elfisa.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Базовые адреса платформы. Совпадают с десктопом WinForms:
|
||||||
|
/// API — https://24pharmdata.ru, ИИ-отчёты — https://24pharmdata.ru/ai.
|
||||||
|
/// Можно переопределить переменными окружения.
|
||||||
|
/// </summary>
|
||||||
|
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 fromSettings = SettingsStore.Current.ApiBaseUrl;
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromSettings)) return fromSettings.Trim().TrimEnd('/');
|
||||||
|
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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Elfisa.Core/DraftStore.cs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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 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.Drafts.Insert(0, draft);
|
||||||
|
Save();
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Remove(string id)
|
||||||
|
{
|
||||||
|
_box.Drafts.RemoveAll(d => d.Id == id);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/Elfisa.Core/Dtos.cs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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<BuyerReportLineDto>? 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<byte>();
|
||||||
|
public string FileName { get; set; } = "report.docx";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ErrorResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("error")] public string? Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PriceSummaryResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("summary")] public List<PriceItem>? Summary { get; set; }
|
||||||
|
[JsonPropertyName("total_drugs")] public int TotalDrugs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PriceItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("supplier_price_id")] public string? SupplierPriceId { get; set; }
|
||||||
|
[JsonPropertyName("supplier_name")] public string? SupplierName { get; set; }
|
||||||
|
[JsonPropertyName("drug_name")] public string? DrugName { get; set; }
|
||||||
|
[JsonPropertyName("cure_form")] public string? CureForm { get; set; }
|
||||||
|
[JsonPropertyName("barcode")] public string? Barcode { get; set; }
|
||||||
|
[JsonPropertyName("price")] public decimal Price { get; set; }
|
||||||
|
[JsonPropertyName("quantity")] public decimal Quantity { get; set; }
|
||||||
|
[JsonPropertyName("manufacturer")] public string? Manufacturer { get; set; }
|
||||||
|
[JsonPropertyName("trade_name")] public string? TradeName { get; set; }
|
||||||
|
[JsonPropertyName("es_code")] public long EsCode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuyerLocationDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("buyer_location_id")] public string? BuyerLocationId { get; set; }
|
||||||
|
[JsonPropertyName("address")] public string? Address { get; set; }
|
||||||
|
[JsonPropertyName("region_name")] public string? RegionName { get; set; }
|
||||||
|
[JsonPropertyName("is_default")] public bool IsDefault { get; set; }
|
||||||
|
public override string ToString() => Address ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Подключённый покупателю прайс-лист («Мои прайсы»).</summary>
|
||||||
|
public sealed class BuyerPriceListDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("price_list_id")] public string? PriceListId { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("supplier_name")] public string? SupplierName { get; set; }
|
||||||
|
[JsonPropertyName("markup_pct")] public double MarkupPct { get; set; }
|
||||||
|
[JsonPropertyName("default_markup_pct")] public double DefaultMarkupPct { get; set; }
|
||||||
|
public override string ToString() =>
|
||||||
|
string.IsNullOrWhiteSpace(SupplierName) ? (Name ?? "Прайс")
|
||||||
|
: $"{Name} · {SupplierName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuyerOrderItemReq
|
||||||
|
{
|
||||||
|
[JsonPropertyName("supplier_price_id")] public string SupplierPriceId { get; set; } = "";
|
||||||
|
[JsonPropertyName("qty")] public double Qty { get; set; }
|
||||||
|
[JsonPropertyName("item_name")] public string? ItemName { get; set; }
|
||||||
|
[JsonPropertyName("barcode")] public string? Barcode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuyerOrderCreateRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("location_id")] public string? LocationId { get; set; }
|
||||||
|
[JsonPropertyName("comment")] public string? Comment { get; set; }
|
||||||
|
[JsonPropertyName("global_sign")] public string? GlobalSign { get; set; }
|
||||||
|
[JsonPropertyName("items")] public List<BuyerOrderItemReq> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuyerOrderResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("order_id")] public string? OrderId { get; set; }
|
||||||
|
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||||
|
[JsonPropertyName("global_sign")] public string? GlobalSign { get; set; }
|
||||||
|
[JsonPropertyName("total_amount")] public decimal TotalAmount { get; set; }
|
||||||
|
[JsonPropertyName("items_count")] public int ItemsCount { get; set; }
|
||||||
|
}
|
||||||
9
src/Elfisa.Core/Elfisa.Core.csproj
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
9
src/Elfisa.Core/PriceCache.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Elfisa.Core;
|
||||||
|
|
||||||
|
/// <summary>Загруженный прайс в памяти (наполняется разделом «Загрузить»).</summary>
|
||||||
|
public static class PriceCache
|
||||||
|
{
|
||||||
|
public static List<PriceItem> Items { get; set; } = new();
|
||||||
|
public static DateTime? LoadedAt { get; set; }
|
||||||
|
public static bool HasData => Items.Count > 0;
|
||||||
|
}
|
||||||
20
src/Elfisa.Core/Session.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
namespace Elfisa.Core;
|
||||||
|
|
||||||
|
/// <summary>Текущая сессия пользователя (токен, роль, логин).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Elfisa.Core/SettingsStore.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Elfisa.Core;
|
||||||
|
|
||||||
|
public sealed class AppSettings
|
||||||
|
{
|
||||||
|
public string? ApiBaseUrl { get; set; }
|
||||||
|
public string Theme { get; set; } = "Light"; // Light | Dark
|
||||||
|
public string? DefaultLocationId { get; set; }
|
||||||
|
public string? Token { get; set; } // сохранённая сессия
|
||||||
|
public string? Role { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Настройки приложения в %APPDATA%\ElfisaAvalonia\settings.json.</summary>
|
||||||
|
public static class SettingsStore
|
||||||
|
{
|
||||||
|
private static readonly string Dir =
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ElfisaAvalonia");
|
||||||
|
private static readonly string File = Path.Combine(Dir, "settings.json");
|
||||||
|
|
||||||
|
public static AppSettings Current { get; private set; } = Load();
|
||||||
|
|
||||||
|
public static AppSettings Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (System.IO.File.Exists(File))
|
||||||
|
return JsonSerializer.Deserialize<AppSettings>(System.IO.File.ReadAllText(File)) ?? new AppSettings();
|
||||||
|
}
|
||||||
|
catch { /* повреждённый файл — начинаем с чистых настроек */ }
|
||||||
|
return new AppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Dir);
|
||||||
|
System.IO.File.WriteAllText(File, JsonSerializer.Serialize(Current, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
}
|
||||||
|
catch { /* нет прав/диска — молча пропускаем */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
261
web/download/index.html
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Скачать ЭльФиСА — Электронная Фармация</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%230F766E'/><rect x='42' y='20' width='16' height='60' rx='5' fill='%23fff'/><rect x='20' y='42' width='60' height='16' rx='5' fill='%23fff'/></svg>">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#eef3f2; --bg2:#f7faf9; --panel:#ffffff; --panel-2:#f2f6f5;
|
||||||
|
--ink:#0c1a17; --ink-soft:#38504a; --muted:#6b827c;
|
||||||
|
--line:#dbe6e3; --line-soft:#e7efed;
|
||||||
|
--brand:#0f766e; --brand-2:#14b8a6; --brand-ink:#0b5750; --brand-soft:#d8ebe8;
|
||||||
|
--shadow:0 1px 2px rgba(12,26,23,.05), 0 12px 34px -12px rgba(12,26,23,.16);
|
||||||
|
--shadow-lift:0 2px 6px rgba(12,26,23,.08), 0 30px 60px -20px rgba(15,118,110,.32);
|
||||||
|
--radius:18px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:dark){
|
||||||
|
:root{
|
||||||
|
--bg:#0a1512; --bg2:#0c1a16; --panel:#10201c; --panel-2:#152825;
|
||||||
|
--ink:#eaf4f1; --ink-soft:#b6cbc5; --muted:#7f9791;
|
||||||
|
--line:#20342f; --line-soft:#1a2c28;
|
||||||
|
--brand:#2dd4bf; --brand-2:#5eead4; --brand-ink:#99f6e4; --brand-soft:#12312c;
|
||||||
|
--shadow:0 1px 2px rgba(0,0,0,.4), 0 18px 44px -16px rgba(0,0,0,.6);
|
||||||
|
--shadow-lift:0 2px 8px rgba(0,0,0,.5), 0 36px 70px -24px rgba(0,0,0,.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="light"]{
|
||||||
|
--bg:#eef3f2; --bg2:#f7faf9; --panel:#ffffff; --panel-2:#f2f6f5;
|
||||||
|
--ink:#0c1a17; --ink-soft:#38504a; --muted:#6b827c;
|
||||||
|
--line:#dbe6e3; --line-soft:#e7efed;
|
||||||
|
--brand:#0f766e; --brand-2:#14b8a6; --brand-ink:#0b5750; --brand-soft:#d8ebe8;
|
||||||
|
--shadow:0 1px 2px rgba(12,26,23,.05), 0 12px 34px -12px rgba(12,26,23,.16);
|
||||||
|
--shadow-lift:0 2px 6px rgba(12,26,23,.08), 0 30px 60px -20px rgba(15,118,110,.32);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"]{
|
||||||
|
--bg:#0a1512; --bg2:#0c1a16; --panel:#10201c; --panel-2:#152825;
|
||||||
|
--ink:#eaf4f1; --ink-soft:#b6cbc5; --muted:#7f9791;
|
||||||
|
--line:#20342f; --line-soft:#1a2c28;
|
||||||
|
--brand:#2dd4bf; --brand-2:#5eead4; --brand-ink:#99f6e4; --brand-soft:#12312c;
|
||||||
|
--shadow:0 1px 2px rgba(0,0,0,.4), 0 18px 44px -16px rgba(0,0,0,.6);
|
||||||
|
--shadow-lift:0 2px 8px rgba(0,0,0,.5), 0 36px 70px -24px rgba(0,0,0,.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{-webkit-text-size-adjust:100%}
|
||||||
|
body{
|
||||||
|
margin:0; color:var(--ink); background:var(--bg);
|
||||||
|
font-family:"Segoe UI Variable Text","Segoe UI",-apple-system,BlinkMacSystemFont,"Inter",system-ui,Roboto,Arial,sans-serif;
|
||||||
|
line-height:1.55; -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1100px 600px at 82% -8%, color-mix(in srgb, var(--brand) 20%, transparent), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 6% 4%, color-mix(in srgb, var(--brand-2) 12%, transparent), transparent 55%);
|
||||||
|
background-attachment:fixed;
|
||||||
|
}
|
||||||
|
.wrap{max-width:1060px; margin:0 auto; padding:0 22px}
|
||||||
|
|
||||||
|
/* ---- header ---- */
|
||||||
|
header{padding:22px 0 6px}
|
||||||
|
.bar{display:flex; align-items:center; justify-content:space-between; gap:16px}
|
||||||
|
.brand{display:flex; align-items:center; gap:12px; text-decoration:none; color:inherit}
|
||||||
|
.logo{width:40px; height:40px; border-radius:12px; background:linear-gradient(150deg,var(--brand),var(--brand-2));
|
||||||
|
display:grid; place-items:center; box-shadow:0 6px 18px -6px color-mix(in srgb,var(--brand) 60%, transparent); flex:0 0 auto}
|
||||||
|
.logo svg{width:22px; height:22px}
|
||||||
|
.brand b{font-size:17px; font-weight:700; letter-spacing:.2px; display:block; line-height:1.1}
|
||||||
|
.brand span{font-size:12px; color:var(--muted)}
|
||||||
|
.top-link{color:var(--ink-soft); text-decoration:none; font-size:13.5px; font-weight:600;
|
||||||
|
padding:9px 15px; border:1px solid var(--line); border-radius:11px; background:var(--panel); transition:.16s}
|
||||||
|
.top-link:hover{border-color:var(--brand); color:var(--brand)}
|
||||||
|
|
||||||
|
/* ---- hero ---- */
|
||||||
|
.hero{padding:52px 0 30px; text-align:center; max-width:760px; margin:0 auto}
|
||||||
|
.eyebrow{display:inline-flex; align-items:center; gap:8px; font-size:12px; font-weight:700;
|
||||||
|
letter-spacing:.10em; text-transform:uppercase; color:var(--brand-ink);
|
||||||
|
background:var(--brand-soft); border:1px solid color-mix(in srgb,var(--brand) 28%, transparent);
|
||||||
|
padding:6px 13px; border-radius:999px}
|
||||||
|
.dot{width:7px; height:7px; border-radius:50%; background:var(--brand-2); box-shadow:0 0 0 3px color-mix(in srgb,var(--brand-2) 30%, transparent)}
|
||||||
|
h1{font-size:clamp(32px,5.2vw,52px); line-height:1.05; letter-spacing:-.02em; margin:20px 0 14px; text-wrap:balance; font-weight:800}
|
||||||
|
h1 .accent{background:linear-gradient(120deg,var(--brand),var(--brand-2)); -webkit-background-clip:text; background-clip:text; color:transparent}
|
||||||
|
.lead{font-size:clamp(16px,2.1vw,19px); color:var(--ink-soft); margin:0 auto; max-width:56ch}
|
||||||
|
|
||||||
|
.cta{margin-top:30px; display:flex; flex-direction:column; align-items:center; gap:12px}
|
||||||
|
.btn-primary{display:inline-flex; align-items:center; gap:12px; text-decoration:none;
|
||||||
|
background:linear-gradient(135deg,var(--brand),var(--brand-2)); color:#fff; font-weight:700; font-size:16.5px;
|
||||||
|
padding:16px 28px; border-radius:14px; box-shadow:var(--shadow-lift); transition:transform .14s, box-shadow .14s; border:0}
|
||||||
|
.btn-primary:hover{transform:translateY(-2px)}
|
||||||
|
.btn-primary:active{transform:translateY(0)}
|
||||||
|
.btn-primary svg{width:22px; height:22px; fill:currentColor}
|
||||||
|
.cta-meta{font-size:13.5px; color:var(--muted)}
|
||||||
|
.cta-meta a{color:var(--brand); text-decoration:none; font-weight:600}
|
||||||
|
.cta-meta a:hover{text-decoration:underline}
|
||||||
|
|
||||||
|
/* ---- os cards ---- */
|
||||||
|
.os{padding:20px 0 8px}
|
||||||
|
.os-grid{display:grid; grid-template-columns:repeat(3,1fr); gap:16px}
|
||||||
|
@media (max-width:760px){ .os-grid{grid-template-columns:1fr} }
|
||||||
|
.card{position:relative; background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
|
||||||
|
padding:22px 20px; box-shadow:var(--shadow); display:flex; flex-direction:column; gap:2px; transition:.18s}
|
||||||
|
.card:hover{transform:translateY(-3px); box-shadow:var(--shadow-lift)}
|
||||||
|
.card.you{border-color:color-mix(in srgb,var(--brand) 55%, transparent);
|
||||||
|
box-shadow:0 0 0 1px color-mix(in srgb,var(--brand) 45%, transparent), var(--shadow-lift)}
|
||||||
|
.youbadge{position:absolute; top:14px; right:14px; font-size:11px; font-weight:700; letter-spacing:.04em;
|
||||||
|
text-transform:uppercase; color:#fff; background:linear-gradient(135deg,var(--brand),var(--brand-2));
|
||||||
|
padding:4px 10px; border-radius:999px; display:none}
|
||||||
|
.card.you .youbadge{display:inline-block}
|
||||||
|
.os-ico{width:46px; height:46px; border-radius:12px; background:var(--panel-2); border:1px solid var(--line-soft);
|
||||||
|
display:grid; place-items:center; margin-bottom:14px}
|
||||||
|
.os-ico svg{width:26px; height:26px; fill:var(--ink)}
|
||||||
|
.card h3{margin:0; font-size:19px; font-weight:700}
|
||||||
|
.os-sub{color:var(--muted); font-size:13px; margin:2px 0 0}
|
||||||
|
.specs{margin:16px 0 18px; display:flex; flex-direction:column; gap:7px; font-size:13.5px}
|
||||||
|
.spec{display:flex; justify-content:space-between; gap:10px; color:var(--ink-soft)}
|
||||||
|
.spec b{font-weight:600; color:var(--ink); font-variant-numeric:tabular-nums}
|
||||||
|
.dl{margin-top:auto; display:inline-flex; align-items:center; justify-content:center; gap:9px; text-decoration:none;
|
||||||
|
font-weight:650; font-size:14.5px; padding:12px 16px; border-radius:12px; transition:.16s;
|
||||||
|
background:var(--brand-soft); color:var(--brand-ink); border:1px solid color-mix(in srgb,var(--brand) 26%, transparent)}
|
||||||
|
.dl:hover{background:var(--brand); color:#fff}
|
||||||
|
.dl svg{width:17px; height:17px; fill:currentColor}
|
||||||
|
|
||||||
|
/* ---- features ---- */
|
||||||
|
.feats{padding:30px 0 8px; display:grid; grid-template-columns:repeat(3,1fr); gap:14px}
|
||||||
|
@media (max-width:760px){ .feats{grid-template-columns:1fr} }
|
||||||
|
.feat{display:flex; gap:12px; align-items:flex-start; padding:16px; border-radius:14px;
|
||||||
|
background:color-mix(in srgb,var(--panel) 60%, transparent); border:1px solid var(--line-soft)}
|
||||||
|
.feat .fi{flex:0 0 auto; width:36px; height:36px; border-radius:10px; background:var(--brand-soft);
|
||||||
|
display:grid; place-items:center; color:var(--brand-ink)}
|
||||||
|
.feat .fi svg{width:19px; height:19px; fill:currentColor}
|
||||||
|
.feat h4{margin:2px 0 3px; font-size:14.5px}
|
||||||
|
.feat p{margin:0; font-size:13px; color:var(--muted); line-height:1.45}
|
||||||
|
|
||||||
|
/* ---- footer ---- */
|
||||||
|
footer{margin-top:34px; border-top:1px solid var(--line); padding:22px 0 40px;
|
||||||
|
display:flex; flex-wrap:wrap; gap:12px 22px; align-items:center; justify-content:space-between; color:var(--muted); font-size:13px}
|
||||||
|
footer a{color:var(--ink-soft); text-decoration:none; font-weight:600}
|
||||||
|
footer a:hover{color:var(--brand)}
|
||||||
|
.foot-links{display:flex; gap:20px; flex-wrap:wrap}
|
||||||
|
@media (prefers-reduced-motion:reduce){ *{transition:none!important} .btn-primary:hover,.card:hover{transform:none} }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<div class="bar">
|
||||||
|
<a class="brand" href="https://24pharmdata.ru">
|
||||||
|
<span class="logo"><svg viewBox="0 0 100 100"><rect x="42" y="18" width="16" height="64" rx="5" fill="#fff"/><rect x="18" y="42" width="64" height="16" rx="5" fill="#fff"/></svg></span>
|
||||||
|
<span><b>ЭльФиСА</b><span>Электронная Фармация</span></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Заказ лекарств для аптеки — <span class="accent">на вашем компьютере</span></h1>
|
||||||
|
<p class="lead">Прайс-листы поставщиков, сборка и отправка заказов, накладные и отчёты. Одно приложение для Windows, macOS и Linux.</p>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<a class="btn-primary" id="heroBtn" href="ElfisaPharmacy-Setup.exe" download>
|
||||||
|
<svg id="heroIco" viewBox="0 0 24 24"><path d="M3 5.5 10.5 4.4v7.1H3zM10.5 12.5v7.1L3 18.5v-6zM11.6 4.2 21 3v8.5h-9.4zM21 12.5V21l-9.4-1.3v-7.2z"/></svg>
|
||||||
|
<span id="heroLbl">Скачать для Windows</span>
|
||||||
|
</a>
|
||||||
|
<div class="cta-meta"><span id="heroMeta">Windows 10/11 · 64-бит · ~90 МБ</span> · <a href="#os">другие системы ↓</a></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="os" id="os">
|
||||||
|
<div class="os-grid">
|
||||||
|
|
||||||
|
<div class="card" data-os="windows">
|
||||||
|
<span class="youbadge">Ваша система</span>
|
||||||
|
<div class="os-ico"><svg viewBox="0 0 24 24"><path d="M3 5.5 10.5 4.4v7.1H3zM10.5 12.5v7.1L3 18.5v-6zM11.6 4.2 21 3v8.5h-9.4zM21 12.5V21l-9.4-1.3v-7.2z"/></svg></div>
|
||||||
|
<h3>Windows</h3>
|
||||||
|
<p class="os-sub">Установщик · рекомендуется</p>
|
||||||
|
<div class="specs">
|
||||||
|
<div class="spec"><span>Формат</span><b>.exe установщик</b></div>
|
||||||
|
<div class="spec"><span>Система</span><b>Windows 10 / 11</b></div>
|
||||||
|
<div class="spec"><span>Разрядность</span><b>64-бит</b></div>
|
||||||
|
<div class="spec"><span>Размер</span><b>~90 МБ</b></div>
|
||||||
|
</div>
|
||||||
|
<a class="dl" href="ElfisaPharmacy-Setup.exe" download>
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Скачать установщик
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-os="mac">
|
||||||
|
<span class="youbadge">Ваша система</span>
|
||||||
|
<div class="os-ico"><svg viewBox="0 0 24 24"><path d="M16.4 12.7c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.9-3.5.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.2 0 2-1.1 2.8-2.2.5-.8.9-1.6 1.2-2.5-.1 0-2.1-.9-2.1-3.2zM14.2 5.9c.6-.8 1-1.8.9-2.9-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.2z"/></svg></div>
|
||||||
|
<h3>macOS</h3>
|
||||||
|
<p class="os-sub">Образ диска · Apple Silicon и Intel</p>
|
||||||
|
<div class="specs">
|
||||||
|
<div class="spec"><span>Формат</span><b>.dmg образ</b></div>
|
||||||
|
<div class="spec"><span>Система</span><b>macOS 12+</b></div>
|
||||||
|
<div class="spec"><span>Процессор</span><b>Apple / Intel</b></div>
|
||||||
|
<div class="spec"><span>Размер</span><b>~95 МБ</b></div>
|
||||||
|
</div>
|
||||||
|
<a class="dl" href="Elfisa-macOS.dmg" download>
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Скачать образ .dmg
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-os="linux">
|
||||||
|
<span class="youbadge">Ваша система</span>
|
||||||
|
<div class="os-ico"><svg viewBox="0 0 24 24"><path d="M12 2c-1.9 0-3 1.7-3 3.6 0 1 .1 1.9-.4 2.8-.5.9-1.6 1.7-2.3 3.2-.6 1.3-.9 2.4-1.6 3.1-.5.5-1 .8-1 1.4 0 .5.4.7 1 .9.8.3 1.3.4 1.6 1 .3.6.3 1.3 1.1 1.6.7.3 1.7 0 2.5-.2.8-.2 1.5-.2 2.3 0 .8.2 1.8.5 2.5.2.8-.3.8-1 1.1-1.6.3-.6.8-.7 1.6-1 .6-.2 1-.4 1-.9 0-.6-.5-.9-1-1.4-.7-.7-1-1.8-1.6-3.1-.7-1.5-1.8-2.3-2.3-3.2-.5-.9-.4-1.8-.4-2.8C15 3.7 13.9 2 12 2zm-1.4 3.9c.3 0 .6.4.6.9s-.3.9-.6.9-.6-.4-.6-.9.3-.9.6-.9zm2.8 0c.3 0 .6.4.6.9s-.3.9-.6.9-.6-.4-.6-.9.3-.9.6-.9zM12 8.4c.9 0 2 .5 2 1 0 .3-.4.4-.8.6-.4.3-.9.7-1.2.7-.3 0-.8-.4-1.2-.7-.4-.2-.8-.3-.8-.6 0-.5 1.1-1 2-1z"/></svg></div>
|
||||||
|
<h3>Linux</h3>
|
||||||
|
<p class="os-sub">AppImage · запуск без установки</p>
|
||||||
|
<div class="specs">
|
||||||
|
<div class="spec"><span>Формат</span><b>.AppImage</b></div>
|
||||||
|
<div class="spec"><span>Система</span><b>glibc 2.31+</b></div>
|
||||||
|
<div class="spec"><span>Разрядность</span><b>x86-64</b></div>
|
||||||
|
<div class="spec"><span>Размер</span><b>~95 МБ</b></div>
|
||||||
|
</div>
|
||||||
|
<a class="dl" href="Elfisa-Linux-x86_64.AppImage" download>
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Скачать AppImage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feats">
|
||||||
|
<div class="feat"><span class="fi"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||||
|
<div><h4>Прайс-листы поставщиков</h4><p>Поиск по названию, дозировке и предложениям — заказ собирается в пару кликов.</p></div></div>
|
||||||
|
<div class="feat"><span class="fi"><svg viewBox="0 0 24 24"><path d="m3 12 4 4L21 4M3 20h12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||||
|
<div><h4>Заказы и накладные</h4><p>Отправка заказов поставщикам, приём накладных и выгрузка в нужном формате.</p></div></div>
|
||||||
|
<div class="feat"><span class="fi"><svg viewBox="0 0 24 24"><path d="M4 19V5m4 14V9m4 10V4m4 15v-7m4 7V8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||||
|
<div><h4>Отчёты и ИИ</h4><p>Сводки по закупкам и отчёты по вашему запросу на понятном языке.</p></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div>© PharmData · ЭльФиСА</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var files = { windows:"ElfisaPharmacy-Setup.exe", mac:"Elfisa-macOS.dmg", linux:"Elfisa-Linux-x86_64.AppImage" };
|
||||||
|
var labels = { windows:"Скачать для Windows", mac:"Скачать для macOS", linux:"Скачать для Linux" };
|
||||||
|
var metas = { windows:"Windows 10/11 · 64-бит · ~90 МБ", mac:"macOS 12+ · Apple/Intel · ~95 МБ", linux:"x86-64 · glibc 2.31+ · ~95 МБ" };
|
||||||
|
|
||||||
|
function detect(){
|
||||||
|
var ua = navigator.userAgent || "";
|
||||||
|
var plat = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "").toLowerCase();
|
||||||
|
if (plat.indexOf("win")>-1 || /Windows/i.test(ua)) return "windows";
|
||||||
|
if (plat.indexOf("mac")>-1 || (/Mac/i.test(ua) && !/iPhone|iPad/i.test(ua))) return "mac";
|
||||||
|
if (plat.indexOf("linux")>-1 || /Linux|X11|Ubuntu|Fedora/i.test(ua)) return "linux";
|
||||||
|
return "windows";
|
||||||
|
}
|
||||||
|
|
||||||
|
var os = detect();
|
||||||
|
var hero = document.getElementById("heroBtn");
|
||||||
|
if (hero){ hero.setAttribute("href", files[os]); }
|
||||||
|
var lbl = document.getElementById("heroLbl"); if(lbl) lbl.textContent = labels[os];
|
||||||
|
var meta = document.getElementById("heroMeta"); if(meta) meta.textContent = metas[os];
|
||||||
|
|
||||||
|
var card = document.querySelector('.card[data-os="'+os+'"]');
|
||||||
|
if (card){ card.classList.add("you"); }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||