Compare commits

...

29 Commits

Author SHA1 Message Date
98a024ba10 Merge pull request '������� �� Avalonia 2.0 (�����-���������, ����������, �������� ��������)' (#1) from feature/avalonia-ui into master 2026-08-11 09:41:06 +03:00
Exest
f98978a5d2 2.0.1: «Мои прайсы» в Прайс-листе + подключённые прайсы в «Загрузить»
- Прайс-лист: дропдаун «Мои прайсы» (подключённые прайсы + «Все прайсы»),
  выбор фильтрует каталог по price_list_id; один прайс — выбирается сам
- Загрузить: показывает список подключённых прайсов и качает их
- Core: GetBuyerPriceListsAsync + priceListId в GetPriceSummaryAsync

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 17:42:53 +03:00
Exest
028c588d2a Релиз 2.0: кросс-платформа, брендовый установщик, страница загрузки
- Версия 2.0.0, self-contained публикация под win/osx/linux
- Установщик Inno перенастроен на Avalonia + брендовые картинки мастера
  (бирюзовый градиент, аптечный крест, ЭльФиСА)
- exe переименовывается в «Электронная Фармация.exe» (совместимо с авто-обновлением)
- Иконки приложения 256/512/1024
- Красивая страница загрузки web/download с авто-определением ОС (Win/Mac/Linux)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 12:41:14 +03:00
Exest
3fc825e54b Avalonia: Прайс-лист как в WinForms — дрилдаун Название→МГ→Предложения + ввод количества с клавиатуры
- База/дозировка вычисляются на клиенте (порт BuildBaseName/ExtractDosage)
- Три панели: базовые наименования → дозировки (МГ) → предложения поставщиков
- Колонка «Заказ»: ввод числа с клавиатуры (многозначно), Enter — добавить
- Фикс: null-безопасная привязка KeyDown (падало в конструкторе → пустой экран)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 12:02:41 +03:00
Exest
0536d480cb Avalonia: вкладки-документы + нижняя строка состояния + монохромные иконки
- Вкладки-документы: разделы открываются вкладками (несколько сразу),
  закрываются, активная синхронизирована с верхней навигацией
  (ShellViewModel.Tabs/ActiveTab, CloseTabCommand)
- Нижняя строка состояния: Пользователь | Аптека + кнопка «Настройки»
- Монохромные иконки Segoe MDL2 (те же глифы, что в WinForms):
  Прайс E14C, Заказы E7BF, Накладные E8A5, Отчёты EB05, Загрузить E896,
  Отправить E725, Отказы E7BA, Справочник E8F1
- контент показывает активную вкладку

Оболочка приведена к виду исходной программы. Проверено вживную.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 13:00:03 +03:00
Exest
cd2fb57bca Avalonia: черновики получают реальный номер EX-####### (как в WinForms)
- при «Сохранить заказ» резервируется настоящий номер через
  GET /api/buyer/global-sign/next (fallback ЧРН-… если офлайн)
- при отправке черновика этот EX-номер передаётся в заказ (global_sign),
  заказ сохраняет тот же номер
- ApiClient.NextGlobalSignAsync, BuyerOrderCreateRequest.GlobalSign
- DraftStore: номер задаёт вызывающий

Проверено: черновик = EX-0000018, статус «Не отправлен».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:54:43 +03:00
Exest
2420a71ace Avalonia: сборка заказа в Прайс-листе (панель «Мой заказ») + черновики в «Заказы»
- Прайс-лист: 2 колонки — каталог + «Мой заказ» (состав/кол-во/итог/
  грузополучатель/комментарий) + «Сохранить заказ»
- Сохранить -> локальный черновик (DraftStore, %APPDATA%)
- Заказы: черновики вверху «Не отправлен» + «Отправить заказ»/«Удалить»
- как в WinForms: собрал -> сохранил (не отправлен) -> отправил отдельно

Проверено вживую: сборка, сохранение, черновик в Заказах.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:43:47 +03:00
Exest
c677345ba6 Avalonia: общая корзина, добавление из Прайс-листа, Настройки
- CartService: единая корзина для Прайс-листа и Отправить
- Прайс-лист: выбор позиции + кол-во + «Добавить в заказ», индикатор корзины
- Настройки (диалог ⚙ в шапке): адрес API, тема (свет/тьма, применяется
  сразу), грузополучатель по умолчанию; сохранение в %APPDATA%
- SettingsStore (Core) + AppConfig читает сохранённый API-адрес
- тема применяется на старте

Проверено вживую: добавление из Прайс-листа в корзину, диалог настроек.
Реальная отправка заказа (POST) — собрана и связана, живой заказ по явному «да»
(автозащита блокирует боевую транзакцию без подтверждения).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:26:53 +03:00
Exest
43468ac693 Avalonia: раздел «Загрузить» (скачивание прайса) + уплотнение верхней панели
- Добавлена вкладка «Загрузить» (между Отчёты и Отправить, как в исходной
  программе): постраничная загрузка прайса с прогрессом, кэш в памяти
  (PriceCache), итог «N позиций от K поставщиков» + timestamp
- Верхняя навигация уплотнена под 8 вкладок (Справочник больше не обрезается)
- Core: PriceCache

Проверено вживую: все 8 вкладок влезают, загрузка прайса работает (500 поз.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 11:05:52 +03:00
Exest
b13a867f8d Avalonia: раздел «Отправить» (корзина + создание заказа)
- Отправить: поиск по прайсу → корзина (кол-во через NumericUpDown,
  сумма/итого) → выбор грузополучателя + комментарий → создание заказа
  POST /api/buyer/orders
- Core: CreateOrderAsync + DTO (BuyerOrderCreateRequest/ItemReq/Response)
- Отказы: заглушка с точным пояснением (нужен серверный эндпоинт;
  RefuseItemsCount живёт только в локальной SQLite WinForms)

Итог: 6/7 разделов функциональны (Прайс-лист, Заказы, Накладные, Отчёты,
Справочник, Отправить), Отказы — заглушка. Собирается.
⚠️ Реальная отправка заказа (POST) не тестировалась — создаёт заказ на
проде; проверять вживую отдельно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:30:57 +03:00
Exest
77dfb08ef4 Avalonia: Прайс-лист, Накладные, Справочник (5/7 разделов рабочие)
- Прайс-лист: поиск + грид на рабочем /api/supplier-prices/summary
  (наименование, форма, производитель, поставщик, цена)
- Накладные: реестр приходных документов (из buyer/report)
- Справочник: профиль покупателя + грузополучатели (buyer/locations)
- Core: GetPriceSummaryAsync, GetBuyerLocationsAsync + DTO
- README: статус разделов + примечание про buyer/orders 500 (фикс застейджен)

Рабочие: Прайс-лист, Заказы, Накладные, Отчёты, Справочник.
Осталось: Отправить (корзина+DBF), Отказы (нужен эндпоинт), автообновление.
Собирается; рантайм новых 3 разделов не проверен (клиент за игрой — окна не поднимал).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:20:36 +03:00
Exest
132ce1531f Avalonia: верхняя навигация (как в WinForms) + раздел «Заказы»
- Навигация перенесена из левого сайдбара в ВЕРХНЮЮ горизонтальную
  панель, как в исходной программе: логотип слева, вкладки
  (иконка+подпись) по центру, пользователь+Выйти справа
- Раздел «Заказы»: мастер-детали (список слева, состав заказа справа
  с позициями и итогом), на данных /api/buyer/report
- Числовые колонки выровнены вправо (стиль DataGridCell.num)
- Отчёты: тулбар на WrapPanel, стабильные ширины колонок

Проверено запуском (скриншоты): верхняя навигация, отчёты, заказы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:00:57 +03:00
Exest
f6a1012e9b Avalonia кросс-платформенный клиент: фундамент + раздел «Отчёты»
Порт десктопа с WinForms на Avalonia (.NET 8, Win/macOS/Linux).

- Elfisa.Core: переносимая логика (ApiClient: логин/отчёт/ИИ, DTO, Session)
- Дизайн-система в бирюзе #0F766E (свет/тьма, кнопки/поля/карточки/таблица/сайдбар)
- Экран входа (реальный /auth/login), шелл с сайдбар-навигацией
- «Отчёты»: 4 типа + авто-загрузка + ИИ-запрос -> Word/Excel, динамические колонки
- Заглушки остальных разделов + README с дорожной картой портирования

Проверено запуском: логин, живые данные покупателя, тема, таблица (скриншот).
dev-автологин по ELF_DEV_USER/ELF_DEV_PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:21:10 +03:00
Exest
a4880d7c50 v1.0.29: раздел «Отчёты» в стиле приложения
- бирюзовые кнопки (#0F766E) как в шапке, «Сформировать» больше не обрезано
- убрана криво рендерившаяся эмодзи с кнопки ИИ
- поле «Запрос ИИ» нормальной ширины (не на весь экран), фиксированные позиции
- таблица: бирюзовая шапка с белым текстом, чередование строк,
  бирюзовое выделение, числа выровнены вправо, сбалансированные ширины колонок

Проверено рендером с тестовыми данными.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 16:57:22 +03:00
Exest
4b43907353 v1.0.28: фикс раскладки раздела «Отчёты»
Во второй строке (ИИ) убраны конфликтующие якоря — поле «Запрос ИИ»
тянется по ширине, формат и кнопка пиннятся вправо с пересчётом при
ресайзе, контролы больше не наезжают друг на друга.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 16:45:52 +03:00
Exest
4d398aca84 v1.0.27: ИИ-отчёты по запросу в разделе «Отчёты»
Клиент пишет запрос словами («закупки по поставщикам за июль»,
«топ товаров за 3 месяца», «заказы у Farmsnab»), ИИ на Мак-мини
подбирает тип/период/фильтр, платформа отдаёт данные строго этого
покупателя, собирается Word/Excel и открывается автоматически.

- UCReports: строка «Запрос ИИ» + Word/Excel + кнопка
- ApiClient.GenerateAiReportAsync -> POST {AiBase}/generate
- AppConfig.AiBaseUrl (default https://24pharmdata.ru/ai,
  override ELF_AI_BASE_URL для локального теста)

Сервер: Caddy проксирует /ai/* -> Мак-мини (Ollama qwen2.5:7b +
FastAPI). LLM только распознаёт запрос, цифры считает код.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 16:24:19 +03:00
Exest
f7fb7ed1b0 Release v1.0.26: раздел «Отчёты» для покупателя
Новый раздел «Отчёты» в десктопе (buyer-scoped, данные с сервера):
- Мои заказы за период, По поставщикам, По товарам, По аптекам
- фильтр по датам, агрегация клиентски из одного набора строк
- сервер: GET /api/buyer/report скоупит строки по JWT покупателя
  (клиент видит ТОЛЬКО свои заказы)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 14:06:41 +03:00
Exest
557f8667ad Release v1.0.25: fix auto-updater elevation
Апдейтер: повышение прав через cmd.exe /c вместо запуска .cmd напрямую
(ShellExecute runas на .cmd срабатывал не на всех системах — elevated
процесс молча не стартовал, обновление в Program Files не применялось,
клиент зацикливался на уведомлении). Payload переносится в ASCII-путь,
чтобы имя папки из архива не ломало аргумент SRC для robocopy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 10:38:08 +03:00
Exest
b10eba0984 Release v1.0.24
Версия десктопа поднята до 1.0.24.0. Сопровождает серверные фиксы,
которые уже на проде: отправка заказа (глобальный номер заявки),
скоуп прайса покупателя по назначенному прайс-листу.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 18:03:00 +03:00
Exest
63abfd4e31 Release v1.0.23: global order numbers EX-####### (GLOBAL_SIGN).
Desktop reserves/sends EX numbers from server; supplier DBF gets GLOBAL_SIGN column.
2026-08-05 18:59:43 +03:00
Exest
d2a1a86794 Release v1.0.22: fix auto-update loop on Program Files installs.
Auto-updater now requests UAC elevation, force-unlocks the exe, and verifies the copy before restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:09:57 +03:00
2659de14b9 Release v1.0.21: consignees from web, remove test pharmacies. 2026-08-05 15:22:44 +03:00
Exest
166a431bda Release v1.0.20: fix Orders date filter hiding new carts.
Compare OrderDate as real dates (dd.MM.yyyy storage broke across month boundaries) and bump AssemblyVersion so desktop picks up the Gitea release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:01:44 +03:00
Exest
cdb2d353a5 Release v1.0.19 desktop build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:21:07 +03:00
Exest
0253bdf022 Bump to v1.0.18 with valid release timestamp.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:21:07 +03:00
Exest
621f6a6cf3 Bump to v1.0.17 retest after hang fix.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:18:59 +03:00
Exest
03c71b8e78 Fix update hang: allow dialog close after download; harden updater wait.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:18:11 +03:00
Exest
2230d4c73f Bump to v1.0.15 auto-update retest target.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:49:23 +03:00
Exest
28f0886b32 Fix auto-update exit crash after download (Application.Exit from async).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:48:56 +03:00
113 changed files with 5992 additions and 426 deletions

10
.gitignore vendored
View File

@ -20,3 +20,13 @@ Desktop.ini
efClient.db
*.db-journal
**/user.config
packages/
installer/output/
dist/
# Кросс-платформенные публикации и артефакты сборки
publish/
build/
installer/output/

6
Elfisa.Cross.slnx Normal file
View 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>

View File

@ -29,6 +29,9 @@
<setting name="IntDefaultPercentMarkup" serializeAs="String">
<value>0</value>
</setting>
<setting name="ClientMarkupPercent" serializeAs="String">
<value>0</value>
</setting>
<setting name="stringLogin" serializeAs="String">
<value />
</setting>
@ -38,6 +41,21 @@
<setting name="stringToken" serializeAs="String">
<value />
</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>
</userSettings>
<runtime>

View File

@ -0,0 +1,108 @@
; ЭльФиСА — установщик «Электронная Фармация»
; Кодировка: UTF-8 with BOM
#define MyAppName "Электронная Фармация"
#define MyAppBrand "ЭльФиСА"
#define MyAppVersion "2.0.1"
#define MyAppPublisher "PharmData"
#define MyAppURL "https://cdn.24pharmdata.ru"
#define MyAppSupportURL "https://24pharmdata.ru"
#define MyAppExeName "Электронная Фармация.exe"
#define MyAppId "{{A7C4E2B1-9F3D-4E6A-8B2C-1D5F7A9E0C42}"
[Setup]
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppBrand} — {#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppSupportURL}
AppSupportURL={#MyAppSupportURL}
AppUpdatesURL={#MyAppURL}
; Программа — в LocalAppData (без прав админа). База/логи — тоже в профиле пользователя.
DefaultDirName={localappdata}\{#MyAppBrand}\{#MyAppName}
DefaultGroupName={#MyAppBrand}
DisableProgramGroupPage=no
LicenseFile=license.txt
InfoBeforeFile=welcome.txt
OutputDir=output
OutputBaseFilename=ElfisaPharmacy-Setup-{#MyAppVersion}
SetupIconFile=assets\elfisa.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
UninstallDisplayName={#MyAppBrand} — {#MyAppName}
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
WizardSizePercent=120
WizardImageFile=assets\wizard-side.bmp
WizardSmallImageFile=assets\wizard-small.bmp
WizardImageStretch=yes
AllowNoIcons=yes
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
ArchitecturesInstallIn64BitMode=x64compatible
VersionInfoVersion={#MyAppVersion}.0
VersionInfoCompany={#MyAppPublisher}
VersionInfoDescription=Установщик {#MyAppBrand} — {#MyAppName}
VersionInfoProductName={#MyAppName}
VersionInfoProductVersion={#MyAppVersion}
VersionInfoCopyright=Copyright (C) {#MyAppPublisher}
ShowLanguageDialog=no
DisableWelcomePage=no
DisableDirPage=no
DisableFinishedPage=no
CloseApplications=yes
RestartApplications=no
SetupLogging=yes
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
[Messages]
russian.BeveledLabel={#MyAppBrand} · PharmData
russian.WelcomeLabel1=Добро пожаловать в установку [name]
russian.WelcomeLabel2=Мастер установит [name/ver] на ваш компьютер.%n%nРекомендуется закрыть все другие приложения перед продолжением.%n%nНажмите «Далее», чтобы продолжить.
russian.FinishedLabel=Установка [name] успешно завершена.%n%nПриложение готово к работе. При первом запуске выполните вход в систему.
russian.ClickFinish=Нажмите «Готово», чтобы выйти из мастера установки.
russian.SelectTasksDesc=Выберите дополнительные задачи, затем нажмите «Далее».
russian.SelectTasksLabel2=Какие дополнительные задачи нужно выполнить?
russian.WizardSelectTasks=Дополнительные задачи
russian.WizardReady=Всё готово к установке
russian.ReadyLabel1=Мастер готов установить [name] на ваш компьютер.
russian.ReadyLabel2a=Нажмите «Установить», чтобы продолжить, или «Назад», если хотите просмотреть либо изменить параметры.
russian.InstallingLabel=Подождите, пока [name] устанавливается на ваш компьютер…
russian.StatusExtractFiles=Распаковка файлов…
russian.StatusCreateDirs=Создание папок…
russian.StatusRegisterFiles=Регистрация компонентов…
russian.ConfirmUninstall=Вы действительно хотите удалить %1 и все его компоненты?
russian.UninstalledAll=%1 успешно удалена.
russian.ExitSetupMessage=Установка не завершена. Если вы выйдете сейчас, программа не будет установлена.%n%nВы можете запустить установщик позже.%n%nВыйти из установки?
[CustomMessages]
russian.CreateDesktopIcon=Создать ярлык на рабочем столе
russian.LaunchAfterInstall=Запустить Электронную Фармацию
russian.AppWorkingDir=Рабочая папка программы
russian.AdditionalIcons=Дополнительные ярлыки:
russian.BrandLine=ЭльФиСА — удобный заказ лекарств для аптек
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
; Avalonia self-contained выпуск (весь рантайм .NET внутри — без зависимостей у клиента).
; Сборка кладётся в ..\publish\win-x64 скриптом build-installer.ps1, exe уже переименован
; в «Электронная Фармация.exe» (совместимо с авто-обновлением старых клиентов).
Source: "..\publish\win-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "assets\elfisa.ico"; DestDir: "{app}"; DestName: "elfisa.ico"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\elfisa.ico"; Comment: "{cm:BrandLine}"
Name: "{group}\Удалить {#MyAppName}"; Filename: "{uninstallexe}"; IconFilename: "{app}\elfisa.ico"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\elfisa.ico"; Comment: "{cm:BrandLine}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchAfterInstall}"; Flags: nowait postinstall skipifsilent
[UninstallDelete]
Type: filesandordirs; Name: "{app}\Logs"
Type: files; Name: "{app}\*.pdb"

24
installer/README.md Normal file
View File

@ -0,0 +1,24 @@
# Установщик ЭльФиСА
## Публичная ссылка (CDN)
https://cdn.24pharmdata.ru
При открытии корня CDN сразу скачивается установщик.
Стабильное имя файла:
https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe
## Локальная сборка
- `installer/output/ElfisaPharmacy-Setup-1.0.20.exe`
- `installer/output/ElfisaPharmacy-Setup.exe` (копия для CDN)
## Пересборка
```powershell
.\scripts\build-installer.ps1
```
После сборки залейте `ElfisaPharmacy-Setup.exe` на CDN (или сделайте его index/default document).

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
installer/assets/elfisa.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

18
installer/license.txt Normal file
View File

@ -0,0 +1,18 @@
ЛИЦЕНЗИОННОЕ СОГЛАШЕНИЕ
Электронная Фармация (ЭльФиСА)
1. Программа предоставляется для использования в рамках договора
с поставщиком услуг PharmData / 24pharmdata.
2. Запрещается передавать третьим лицам учётные данные доступа,
а также модифицировать клиент способом, нарушающим безопасность
обмена данными.
3. Пользователь несёт ответственность за корректность оформляемых
заказов и за сохранность локальных данных на рабочем месте.
4. Устанавливая программу, вы подтверждаете согласие с условиями
использования сервиса и политикой обработки данных, действующими
у оператора сервиса.
При возникновении вопросов обратитесь в службу поддержки.

20
installer/welcome.txt Normal file
View File

@ -0,0 +1,20 @@
ЭльФиСА — Электронная Фармация
================================
Добро пожаловать в установку настольного клиента «Электронная Фармация».
Программа предназначена для работы аптек с прайс-листами поставщиков,
заказами, накладными и обменом данными с сервером PharmData.
Скачать установщик всегда можно по адресу:
https://cdn.24pharmdata.ru
Установщик:
• скопирует программу в выбранную папку;
• предложит ярлык в меню «Пуск»;
• по желанию создаст ярлык на рабочем столе.
Рекомендуется закрыть запущенную «Электронную Фармацию» перед установкой.
После установки при первом запуске выполните вход в систему.
© PharmData / ЭльФиСА

View File

@ -0,0 +1,59 @@
#Requires -Version 5.1
param([switch]$SkipBuild)
$ErrorActionPreference = "Stop"
$root = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $root
Write-Host "Root: $root" -ForegroundColor Cyan
$iscc = @(
"$env:LocalAppData\Programs\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $iscc) {
throw "Inno Setup 6 (ISCC.exe) не найден. Установите: winget install JRSoftware.InnoSetup"
}
$pub = "publish\win-x64"
if (-not $SkipBuild) {
Write-Host "Публикация Avalonia (win-x64, self-contained)..." -ForegroundColor Cyan
if (Test-Path $pub) { Remove-Item $pub -Recurse -Force }
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
& $iscc "installer\ElfisaPharmacy.iss"
if ($LASTEXITCODE -ne 0) { throw "Inno Setup завершился с ошибкой" }
$out = Get-ChildItem "installer\output\ElfisaPharmacy-Setup-*.exe" |
Where-Object { $_.Name -notmatch '\.bak\.' -and $_.Name -ne 'ElfisaPharmacy-Setup.exe' } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $out) {
throw "Не найден собранный Setup exe в installer\output"
}
# Стабильное имя для CDN: https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe
$stable = Join-Path $out.DirectoryName "ElfisaPharmacy-Setup.exe"
Copy-Item $out.FullName $stable -Force
Write-Host ""
Write-Host "Готово: $($out.FullName)" -ForegroundColor Green
Write-Host "CDN-файл: $stable" -ForegroundColor Green
Write-Host ("Размер: {0:N1} МБ" -f ($out.Length / 1MB))
Write-Host ""
Write-Host "Залейте на CDN:" -ForegroundColor Cyan
Write-Host " https://cdn.24pharmdata.ru/ <- корень отдаёт этот Setup"
Write-Host " или файл: ElfisaPharmacy-Setup.exe"

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<!-- For more information on Entity Framework configuration, visit http://go.microsoft.com/fwlink/?LinkID=237468 -->

View File

@ -374,6 +374,108 @@ 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()
{
EnsureAuthenticated();
var responseBody = await SendAuthorizedGetAsync("/api/buyer/locations", "загрузку грузополучателей");
try
{
using (var doc = JsonDocument.Parse(responseBody))
{
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return JsonSerializer.Deserialize<List<BuyerLocationDto>>(responseBody, JsonOptions)
?? new List<BuyerLocationDto>();
}
}
return new List<BuyerLocationDto>();
}
catch (Exception ex)
{
AppDebugLog.Error("ApiClient", "Не удалось разобрать грузополучателей", ex);
throw new HttpRequestException($"Некорректный ответ сервера при загрузке грузополучателей: {ex.Message}", ex);
}
}
public async Task<BuyerOrderDetailDto> GetBuyerOrderByIdAsync(string orderId)
{
EnsureAuthenticated();
@ -734,6 +836,87 @@ namespace Электронная_Фармация.Classes
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
{
[JsonPropertyName("buyer_location_id")]
public string BuyerLocationID { get; set; }
[JsonPropertyName("buyer_id")]
public string BuyerID { get; set; }
[JsonPropertyName("address")]
public string Address { get; set; }
[JsonPropertyName("region_id")]
public string RegionID { get; set; }
[JsonPropertyName("is_default")]
public bool IsDefault { get; set; }
[JsonPropertyName("region_name")]
public string RegionName { get; set; }
}
public sealed class BuyerOrderListItemDto
{
[JsonPropertyName("order_id")]

View File

@ -12,11 +12,46 @@ namespace Электронная_Фармация.Classes
public const string DefaultApiBaseUrl = "https://24pharmdata.ru";
public const string DefaultUpdateCheckBaseUrl = "https://git.24pharmdata.ru";
public const string DefaultUpdateRepo = "pharmdata/elfisa-pharmacy";
/// <summary>Публичная раздача установщика (корень CDN отдаёт Setup.exe).</summary>
public const string DefaultInstallerCdnUrl = "https://cdn.24pharmdata.ru";
public const string DefaultInstallerDownloadUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe";
private const string LegacyApiBaseUrl = "http://195.34.241.84:9988";
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 readonly object _sqliteInteropLock = new object();
public static string InstallerDownloadUrl
{
get
{
var fromEnv = Environment.GetEnvironmentVariable("ELF_INSTALLER_URL");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv.Trim();
}
return DefaultInstallerDownloadUrl;
}
}
public static string InstallerCdnUrl
{
get
{
var fromEnv = Environment.GetEnvironmentVariable("ELF_CDN_URL");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv.Trim().TrimEnd('/');
}
return DefaultInstallerCdnUrl;
}
}
/// <summary>
/// Replaces known dead API URLs in saved user settings.
/// </summary>
@ -80,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)
{
if (string.IsNullOrWhiteSpace(url))
@ -97,24 +147,77 @@ namespace Электронная_Фармация.Classes
return url;
}
/// <summary>
/// Writable per-user data (DB, logs). Program Files is read-only for normal users.
/// </summary>
public static string UserDataDirectory
{
get
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ЭльФиСА",
"Электронная Фармация");
try
{
Directory.CreateDirectory(dir);
}
catch
{
// Caller will surface a clearer error when opening the DB.
}
return dir;
}
}
public static string LogsDirectory => Path.Combine(UserDataDirectory, "Logs");
public static string SqliteDbPath
{
get
{
MigrateLegacySqliteDatabaseIfNeeded();
return Path.Combine(UserDataDirectory, "efClient.db");
}
}
/// <summary>
/// One-time copy from install folder (old portable layout) into LocalAppData.
/// </summary>
private static void MigrateLegacySqliteDatabaseIfNeeded()
{
try
{
var target = Path.Combine(UserDataDirectory, "efClient.db");
if (File.Exists(target))
{
return;
}
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var root = Path.Combine(baseDir, "efClient.db");
var data = Path.Combine(baseDir, "Data", "efClient.db");
if (File.Exists(root))
var candidates = new[]
{
return root;
}
Path.Combine(baseDir, "efClient.db"),
Path.Combine(baseDir, "Data", "efClient.db")
};
if (File.Exists(data))
foreach (var source in candidates)
{
return data;
}
if (!File.Exists(source))
{
continue;
}
return root;
File.Copy(source, target, overwrite: false);
AppDebugLog.Info("AppConfig", $"SQLite DB migrated: {source} -> {target}");
return;
}
}
catch (Exception ex)
{
AppDebugLog.Info("AppConfig", $"SQLite DB migrate skipped: {ex.Message}");
}
}

View File

@ -8,7 +8,7 @@ namespace Электронная_Фармация.Classes
{
/// <summary>
/// File logger for API/auth/network debugging.
/// Logs are written to Logs/api-debug-YYYY-MM-DD.log next to the executable.
/// Logs are written to %LocalAppData%\ЭльФиСА\...\Logs\api-debug-YYYY-MM-DD.log.
/// </summary>
public static class AppDebugLog
{
@ -33,8 +33,7 @@ namespace Электронная_Фармация.Classes
}
}
public static string LogDirectory =>
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
public static string LogDirectory => AppConfig.LogsDirectory;
public static string CurrentLogFilePath =>
Path.Combine(LogDirectory, $"api-debug-{DateTime.Now:yyyy-MM-dd}.log");

View File

@ -31,7 +31,6 @@ namespace Электронная_Фармация.Classes
if (string.IsNullOrWhiteSpace(downloadUrl) ||
!downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
// Prefer zipball if asset missing
if (!string.IsNullOrWhiteSpace(downloadUrl) &&
!downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
@ -75,24 +74,74 @@ namespace Электронная_Фармация.Classes
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 updaterBat = Path.Combine(workRoot, "apply-update.cmd");
WriteUpdaterScript(updaterBat);
Report(status, "Подготовка перезапуска...");
Report(status, needsElevation
? "Нужны права администратора для установки в Program Files..."
: "Подготовка перезапуска...");
var batchArgs = Quote(payloadDir) + " " + Quote(installDir) + " " +
Quote(Path.GetFileName(currentExe)) + " " +
Process.GetCurrentProcess().Id;
var psi = new ProcessStartInfo
{
FileName = updaterBat,
Arguments = Quote(payloadDir) + " " + Quote(installDir) + " " +
Quote(Path.GetFileName(currentExe)) + " " +
Process.GetCurrentProcess().Id,
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden,
WorkingDirectory = workRoot,
CreateNoWindow = true
WindowStyle = needsElevation ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden,
CreateNoWindow = !needsElevation
};
Process.Start(psi);
if (needsElevation)
{
// ВАЖНО: повышаем права через cmd.exe (PE-исполняемый), а НЕ через
// .cmd-файл напрямую. ShellExecute с verb=runas на .cmd срабатывает не
// на всех системах (у batch может не быть runas-ассоциации), из-за чего
// elevated-процесс молча не запускается и обновление не применяется.
psi.FileName = "cmd.exe";
psi.Arguments = "/c \"\"" + updaterBat + "\" " + batchArgs + "\"";
psi.Verb = "runas";
}
else
{
psi.FileName = updaterBat;
psi.Arguments = batchArgs;
}
try
{
Process.Start(psi);
}
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
// ERROR_CANCELLED — пользователь отклонил UAC
throw new InvalidOperationException(
"Обновление отменено: нужны права администратора " +
"(программа установлена в Program Files).",
ex);
}
AppDebugLog.Info("AutoUpdater",
$"Updater started. payload={payloadDir}, install={installDir}, ver={update.LatestVersion}");
$"Updater started. elevate={needsElevation}, payload={payloadDir}, install={installDir}, ver={update.LatestVersion}");
}
catch
{
@ -112,6 +161,21 @@ namespace Электронная_Фармация.Classes
}
}
private static bool IsDirectoryWritable(string directory)
{
try
{
var probe = Path.Combine(directory, ".elfisa-write-" + Guid.NewGuid().ToString("N"));
File.WriteAllText(probe, "ok");
File.Delete(probe);
return true;
}
catch
{
return false;
}
}
private static async Task DownloadAsync(string url, string zipPath)
{
using (var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) })
@ -143,7 +207,8 @@ namespace Электронная_Фармация.Classes
{
var name = Path.GetFileName(p) ?? string.Empty;
return !name.EndsWith(".vshost.exe", StringComparison.OrdinalIgnoreCase) &&
!name.Equals("UpdateCheckSmoke.exe", StringComparison.OrdinalIgnoreCase);
!name.Equals("UpdateCheckSmoke.exe", StringComparison.OrdinalIgnoreCase) &&
!name.StartsWith("unins", StringComparison.OrdinalIgnoreCase);
});
return exe == null ? null : Path.GetDirectoryName(exe);
@ -151,28 +216,58 @@ namespace Электронная_Фармация.Classes
private static void WriteUpdaterScript(string path)
{
// Waits for app exit, copies files, keeps local DB/Logs, restarts exe.
// Waits for app exit, force-kills leftovers, renames locked exe,
// copies with robocopy, verifies, restarts. Log: %TEMP%\elfisa-updater.log
var sb = new StringBuilder();
sb.AppendLine("@echo off");
sb.AppendLine("setlocal");
sb.AppendLine("setlocal EnableDelayedExpansion");
sb.AppendLine("set \"SRC=%~1\"");
sb.AppendLine("set \"DST=%~2\"");
sb.AppendLine("set \"EXENAME=%~3\"");
sb.AppendLine("set \"PID=%~4\"");
sb.AppendLine("echo Waiting for PID %PID% ... > \"%TEMP%\\elfisa-updater.log\"");
sb.AppendLine("set \"LOG=%TEMP%\\elfisa-updater.log\"");
sb.AppendLine("echo ===== %DATE% %TIME% ===== > \"%LOG%\"");
sb.AppendLine("echo SRC=%SRC%>> \"%LOG%\"");
sb.AppendLine("echo DST=%DST%>> \"%LOG%\"");
sb.AppendLine("echo EXE=%EXENAME% PID=%PID%>> \"%LOG%\"");
sb.AppendLine("set \"N=0\"");
sb.AppendLine("echo Waiting for PID %PID% ... >> \"%LOG%\"");
sb.AppendLine(":waitloop");
sb.AppendLine("tasklist /FI \"PID eq %PID%\" 2>nul | find \"%PID%\" >nul");
sb.AppendLine("if not errorlevel 1 (");
sb.AppendLine(" timeout /t 1 /nobreak >nul");
sb.AppendLine(" goto waitloop");
sb.AppendLine("tasklist /FI \"PID eq %PID%\" 2>nul | findstr /C:\" %PID% \" >nul");
sb.AppendLine("if errorlevel 1 goto donewait");
sb.AppendLine("set /a N+=1");
sb.AppendLine("if !N! GEQ 40 (");
sb.AppendLine(" echo force kill PID %PID% >> \"%LOG%\"");
sb.AppendLine(" taskkill /PID %PID% /F >nul 2>&1");
sb.AppendLine(" goto donewait");
sb.AppendLine(")");
sb.AppendLine("timeout /t 1 /nobreak >nul");
sb.AppendLine("echo Copying update... >> \"%TEMP%\\elfisa-updater.log\"");
// /E copy tree; exclude local DB and logs from being overwritten/removed.
sb.AppendLine("robocopy \"%SRC%\" \"%DST%\" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /XF efClient.db *.db /XD Logs");
sb.AppendLine("set \"RC=%ERRORLEVEL%\"");
sb.AppendLine("echo robocopy=%RC% >> \"%TEMP%\\elfisa-updater.log\"");
// robocopy 0-7 = success-ish
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
sb.AppendLine("goto waitloop");
sb.AppendLine(":donewait");
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
// Kill any leftover instances by image name (locks Program Files exe).
sb.AppendLine("echo taskkill by name >> \"%LOG%\"");
sb.AppendLine("taskkill /F /IM \"%EXENAME%\" >nul 2>&1");
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
// Rename locked/old exe so copy can proceed even if handle lingered.
sb.AppendLine("if exist \"%DST%\\%EXENAME%.old\" del /F /Q \"%DST%\\%EXENAME%.old\" >nul 2>&1");
sb.AppendLine("if exist \"%DST%\\%EXENAME%\" (");
sb.AppendLine(" move /Y \"%DST%\\%EXENAME%\" \"%DST%\\%EXENAME%.old\" >> \"%LOG%\" 2>&1");
sb.AppendLine(")");
sb.AppendLine("echo Copying update... >> \"%LOG%\"");
// /IS /IT — force overwrite even if timestamps look the same
sb.AppendLine("robocopy \"%SRC%\" \"%DST%\" /E /IS /IT /R:5 /W:1 /NFL /NDL /NJH /NJS /XF efClient.db *.db /XD Logs");
sb.AppendLine("set \"RC=!ERRORLEVEL!\"");
sb.AppendLine("echo robocopy=!RC! >> \"%LOG%\"");
sb.AppendLine("if not exist \"%DST%\\%EXENAME%\" (");
sb.AppendLine(" echo COPY FAILED — restoring old exe >> \"%LOG%\"");
sb.AppendLine(" if exist \"%DST%\\%EXENAME%.old\" move /Y \"%DST%\\%EXENAME%.old\" \"%DST%\\%EXENAME%\" >nul 2>&1");
sb.AppendLine(" echo Не удалось заменить файлы программы. См. %TEMP%\\elfisa-updater.log");
sb.AppendLine(" pause");
sb.AppendLine(" exit /b 1");
sb.AppendLine(")");
sb.AppendLine("if exist \"%DST%\\%EXENAME%.old\" del /F /Q \"%DST%\\%EXENAME%.old\" >nul 2>&1");
sb.AppendLine("echo Starting \"%DST%\\%EXENAME%\" >> \"%LOG%\"");
sb.AppendLine("start \"\" /D \"%DST%\" \"%DST%\\%EXENAME%\"");
sb.AppendLine("exit /b 0");
File.WriteAllText(path, sb.ToString(), Encoding.Default);

View File

@ -322,6 +322,131 @@ namespace Электронная_Фармация.Classes
}
}
/// <summary>
/// Подтягивает грузополучателей с сервера (BuyerLocation) в локальную таблицу Consignees.
/// Источник истины — веб-кабинет; локально только кэш + выбор активной аптеки.
/// </summary>
public static int SyncFromServer(IList<BuyerLocationDto> locations)
{
if (locations == null || locations.Count == 0)
{
return 0;
}
var upserted = 0;
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
using (var tx = connection.BeginTransaction())
{
// Сбрасываем default — выставим по данным сервера.
using (var clearDefault = new SQLiteCommand(
"UPDATE Consignees SET ConsigneesUseAsDefault = 0",
connection,
tx))
{
clearDefault.ExecuteNonQuery();
}
int nextCode;
using (var nextCodeCmd = new SQLiteCommand(
"SELECT ifnull(MAX(codeConsignees), 0) + 1 FROM Consignees",
connection,
tx))
{
nextCode = Convert.ToInt32(nextCodeCmd.ExecuteScalar());
}
foreach (var loc in locations)
{
var locationId = (loc.BuyerLocationID ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(locationId))
{
continue;
}
var address = (loc.Address ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(address))
{
address = "Грузополучатель";
}
var name = address;
if (name.Length > 120)
{
name = name.Substring(0, 120);
}
long? existingId = null;
using (var find = new SQLiteCommand(
@"SELECT idConsignees FROM Consignees
WHERE lower(trim(LocationId)) = lower(@loc)
LIMIT 1",
connection,
tx))
{
find.Parameters.AddWithValue("@loc", locationId);
var found = find.ExecuteScalar();
if (found != null && found != DBNull.Value)
{
existingId = Convert.ToInt64(found);
}
}
if (existingId.HasValue)
{
using (var upd = new SQLiteCommand(
@"UPDATE Consignees
SET ConsigneesName = @name,
ConsigneesAddress = @address,
ConsigneesUseAsDefault = @isDefault,
LocationId = @loc
WHERE idConsignees = @id",
connection,
tx))
{
upd.Parameters.AddWithValue("@name", name);
upd.Parameters.AddWithValue("@address", address);
upd.Parameters.AddWithValue("@isDefault", loc.IsDefault ? 1 : 0);
upd.Parameters.AddWithValue("@loc", locationId);
upd.Parameters.AddWithValue("@id", existingId.Value);
upd.ExecuteNonQuery();
}
}
else
{
using (var ins = new SQLiteCommand(
@"INSERT INTO Consignees
(codeConsignees, ConsigneesName, ConsigneesAddress, ConsigneesUseAsDefault, LocationId)
VALUES (@code, @name, @address, @isDefault, @loc)",
connection,
tx))
{
ins.Parameters.AddWithValue("@code", nextCode++);
ins.Parameters.AddWithValue("@name", name);
ins.Parameters.AddWithValue("@address", address);
ins.Parameters.AddWithValue("@isDefault", loc.IsDefault ? 1 : 0);
ins.Parameters.AddWithValue("@loc", locationId);
ins.ExecuteNonQuery();
}
}
upserted++;
}
tx.Commit();
}
}
var active = GetDefaultConsigneeName();
if (!string.IsNullOrWhiteSpace(active))
{
SetActiveConsignee(active);
}
return upserted;
}
/// <summary>
/// Синхронизирует поля заказа в `PriceList` (Zakaz/SummaZakaza) с корзиной выбранной аптеки.
/// Данные корзины хранятся в TempOrderItems с фильтром по ConsigneeName, а PriceList обновляется

View File

@ -143,6 +143,7 @@ namespace Электронная_Фармация.Classes
string query = @"
SELECT
Orders.Id_Order,
Orders.OrderNumber,
Orders.ConsigneeName,
OrderItems.supplier_price_id,
OrderItems.DrugName,
@ -172,6 +173,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
while (reader.Read())
{
string orderId = reader["Id_Order"].ToString().Trim();
string orderNumber = reader["OrderNumber"] == DBNull.Value
? string.Empty
: reader["OrderNumber"].ToString().Trim();
string consigneeName = reader["ConsigneeName"] == DBNull.Value
? string.Empty
: reader["ConsigneeName"].ToString().Trim();
@ -196,6 +200,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
order = new BuyerOrderRequest
{
LocalOrderId = orderId,
GlobalSign = OrderNumberHelper.IsValidEx(orderNumber)
? OrderNumberHelper.Normalize(orderNumber)
: null,
LocationId = locationId,
Comment = reader["Comment"] == DBNull.Value ? null : reader["Comment"].ToString()
};
@ -324,10 +331,25 @@ WHERE Orders.OrderState = 'НОВЫЙ'
throw new InvalidOperationException("Сервер не вернул order_id созданного заказа.");
}
MarkOrderSent(order.LocalOrderId, createdOrder.OrderID, createdOrder.Status);
AppDebugLog.Info("DataSender", $"Заказ {order.LocalOrderId} отправлен");
// Старые серверы создавали Draft — дожимаем до Placed.
var remoteStatus = createdOrder.Status;
if (!string.Equals(remoteStatus, "Placed", StringComparison.OrdinalIgnoreCase))
{
remoteStatus = await PlaceOrderAsync(client, createdOrder.OrderID) ?? remoteStatus;
}
MarkOrderSent(order.LocalOrderId, createdOrder.OrderID, remoteStatus, createdOrder.GlobalSign);
AppDebugLog.Info("DataSender",
$"Заказ {order.LocalOrderId} отправлен, remote={createdOrder.OrderID}, status={remoteStatus}, sign={createdOrder.GlobalSign}");
return true;
}
catch (InvalidOperationException ex)
{
stopwatch.Stop();
AppDebugLog.Error("DataSender", $"Ошибка отправки заказа {order.LocalOrderId}", ex);
ToastNotification.ShowError(ex.Message);
return false;
}
catch (Exception ex)
{
stopwatch.Stop();
@ -338,7 +360,67 @@ WHERE Orders.OrderState = 'НОВЫЙ'
}
}
private static void MarkOrderSent(string localOrderId, string remoteOrderId, string remoteStatus)
private async Task<string> PlaceOrderAsync(HttpClient client, string remoteOrderId)
{
var placePath = $"{BuyerOrdersEndpoint}/{Uri.EscapeDataString(remoteOrderId)}/place";
var fullUrl = new Uri(new Uri(_baseUrl.TrimEnd('/') + "/"), placePath.TrimStart('/')).ToString();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
AppDebugLog.ApiRequest("POST", fullUrl, string.Empty, $"place={remoteOrderId}");
try
{
using (var content = new StringContent(string.Empty, Encoding.UTF8, "application/json"))
using (HttpResponseMessage response = await client.PostAsync(placePath, content))
{
string responseString = await response.Content.ReadAsStringAsync();
stopwatch.Stop();
AppDebugLog.ApiResponse("POST", fullUrl, (int)response.StatusCode, stopwatch.ElapsedMilliseconds, responseString);
if (!response.IsSuccessStatusCode)
{
AppDebugLog.ApiHttpError(
"POST",
fullUrl,
(int)response.StatusCode,
responseString,
$"place={remoteOrderId}");
throw new InvalidOperationException(
$"Не удалось оформить заказ на сервере (HTTP {(int)response.StatusCode}). " +
"Заказ остался черновиком (Draft).");
}
try
{
using (var doc = JsonDocument.Parse(responseString))
{
if (doc.RootElement.TryGetProperty("status", out var statusEl))
{
return statusEl.GetString();
}
}
}
catch
{
// ignore parse — place succeeded
}
return "Placed";
}
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
stopwatch.Stop();
AppDebugLog.ApiFailure("POST", fullUrl, stopwatch.ElapsedMilliseconds, ex);
throw new InvalidOperationException(
$"Не удалось оформить заказ на сервере: {ex.Message}", ex);
}
}
private static void MarkOrderSent(string localOrderId, string remoteOrderId, string remoteStatus, string globalSign)
{
if (string.IsNullOrWhiteSpace(localOrderId))
{
@ -351,12 +433,18 @@ WHERE Orders.OrderState = 'НОВЫЙ'
using (var cmd = new SQLiteCommand(
@"UPDATE [Orders]
SET [OrderState] = @state,
[RemoteOrderId] = @remoteOrderId
[RemoteOrderId] = @remoteOrderId,
[OrderNumber] = CASE
WHEN @globalSign IS NOT NULL AND length(trim(@globalSign)) > 0 THEN @globalSign
ELSE [OrderNumber]
END
WHERE [Id_Order] = @id AND [OrderState] = 'НОВЫЙ'",
con))
{
cmd.Parameters.AddWithValue("@state", "ОТПРАВЛЕН");
cmd.Parameters.AddWithValue("@remoteOrderId", remoteOrderId ?? string.Empty);
cmd.Parameters.AddWithValue("@globalSign",
string.IsNullOrWhiteSpace(globalSign) ? (object)DBNull.Value : globalSign.Trim());
cmd.Parameters.AddWithValue("@id", localOrderId);
cmd.ExecuteNonQuery();
}
@ -393,6 +481,9 @@ WHERE Orders.OrderState = 'НОВЫЙ'
[JsonPropertyName("location_id")]
public string LocationId { get; set; }
[JsonPropertyName("global_sign")]
public string GlobalSign { get; set; }
[JsonPropertyName("comment")]
public string Comment { get; set; }
@ -422,5 +513,8 @@ WHERE Orders.OrderState = 'НОВЫЙ'
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("global_sign")]
public string GlobalSign { get; set; }
}
}

View File

@ -0,0 +1,157 @@
using System;
using System.Data.SQLite;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Электроннаяармация.Properties;
namespace Электроннаяармация.Classes
{
/// <summary>
/// Глобальные номера заказов вида EX-0000001.
/// Источник истины — сервер (никогда не повторяются между аптеками).
/// Offline fallback — локальный счётчик EX- (потом сервер может заменить при отправке).
/// </summary>
public static class OrderNumberHelper
{
private static readonly Regex ExPattern = new Regex(@"^EX-(\d{1,7})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Выделяет номер для нового локального заказа.
/// Сначала пробует сервер /api/buyer/global-sign/next, иначе локальный EX-#######.
/// </summary>
public static string AllocateForNewOrder()
{
try
{
var fromServer = AllocateFromServerAsync().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(fromServer))
{
return fromServer;
}
}
catch (Exception ex)
{
AppDebugLog.Info("OrderNumber", $"Сервер недоступен для GlobalSign: {ex.Message}");
}
return AllocateLocalFallback();
}
public static async Task<string> AllocateFromServerAsync()
{
var token = Settings.Default.stringToken;
var baseUrl = AppConfig.ApiBaseUrl;
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(baseUrl))
{
return null;
}
using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) })
{
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Trim());
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using (var response = await client.PostAsync("api/buyer/global-sign/next", new StringContent(string.Empty)).ConfigureAwait(false))
{
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
AppDebugLog.Info("OrderNumber", $"global-sign/next HTTP {(int)response.StatusCode}: {body}");
return null;
}
using (var doc = JsonDocument.Parse(body))
{
if (doc.RootElement.TryGetProperty("global_sign", out var el))
{
var sign = el.GetString();
if (IsValidEx(sign))
{
return Normalize(sign);
}
}
}
}
}
return null;
}
public static string AllocateLocalFallback()
{
long max = 0;
try
{
using (var con = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
con.Open();
using (var cmd = new SQLiteCommand(
"SELECT OrderNumber FROM Orders WHERE OrderNumber LIKE 'EX-%'", con))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var n = ParseExNumber(reader[0]?.ToString());
if (n > max)
{
max = n;
}
}
}
}
}
catch (Exception ex)
{
AppDebugLog.Info("OrderNumber", $"Локальный max EX-: {ex.Message}");
}
return FormatEx(max + 1);
}
public static bool IsValidEx(string value)
{
return ParseExNumber(value) > 0;
}
public static string Normalize(string value)
{
var n = ParseExNumber(value);
return n > 0 ? FormatEx(n) : value?.Trim();
}
public static string FormatEx(long n)
{
if (n < 1)
{
n = 1;
}
if (n > 9999999)
{
return "EX-" + n.ToString();
}
return "EX-" + n.ToString("D7");
}
public static long ParseExNumber(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return 0;
}
var m = ExPattern.Match(value.Trim());
if (!m.Success)
{
return 0;
}
return long.TryParse(m.Groups[1].Value, out var n) ? n : 0;
}
}
}

View File

@ -125,15 +125,36 @@ namespace Электронная_Фармация.Classes
}
else if (control is Panel || control is UserControl || control is TableLayoutPanel)
{
if (control.Tag as string == "keep-bg")
var tag = control.Tag as string;
if (tag == "keep-bg")
{
// preserve custom background
}
else if (control.Tag as string == "chrome-child")
else if (tag == "chrome-child")
{
control.BackColor = theme.BackgroundPrimary;
control.ForeColor = theme.TextPrimary;
}
else if (tag == "toolbar")
{
control.BackColor = theme.Surface;
control.ForeColor = theme.TextPrimary;
}
else if (tag == "status-footer")
{
control.BackColor = theme.CartHeaderBackground;
control.ForeColor = theme.TextPrimary;
}
else if (tag == "surface-bar")
{
control.BackColor = theme.SectionHeaderBackground;
control.ForeColor = theme.TextPrimary;
}
else if (tag == "markup-bar")
{
control.BackColor = theme.AccentPrimarySoft;
control.ForeColor = theme.TextPrimary;
}
else
{
control.BackColor = theme.BackgroundSecondary;

View File

@ -17,6 +17,7 @@
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<ApplicationIcon>Resources\elfisa.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
@ -112,6 +113,7 @@
<Compile Include="Classes\ConsigneeHelper.cs" />
<Compile Include="Classes\SqlSyntaxHighlighter.cs" />
<Compile Include="Classes\DataSender.cs" />
<Compile Include="Classes\OrderNumberHelper.cs" />
<Compile Include="Classes\ErrorResponse.cs" />
<Compile Include="Classes\InvoiceExportService.cs" />
<Compile Include="Classes\InvoiceRequestService.cs" />
@ -246,6 +248,9 @@
<Compile Include="UserControls\UCRefusals.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="UserControls\UCReports.cs">
<SubType>UserControl</SubType>
</Compile>
<Compile Include="UserControls\UCPriceList.cs">
<SubType>UserControl</SubType>
</Compile>
@ -353,4 +358,7 @@
<Import Project="packages\EntityFramework.6.4.4\build\EntityFramework.targets" Condition="Exists('packages\EntityFramework.6.4.4\build\EntityFramework.targets')" />
<Import Project="packages\System.ValueTuple.4.6.1\build\net471\System.ValueTuple.targets" Condition="Exists('packages\System.ValueTuple.4.6.1\build\net471\System.ValueTuple.targets')" />
<Import Project="packages\System.Resources.Extensions.8.0.0\buildTransitive\net462\System.Resources.Extensions.targets" Condition="Exists('packages\System.Resources.Extensions.8.0.0\buildTransitive\net462\System.Resources.Extensions.targets')" />
<ItemGroup>
<None Include="Resources\elfisa.ico" />
</ItemGroup>
</Project>

View File

@ -1,4 +1,4 @@
namespace Электроннаяармация
namespace Электроннаяармация
{
partial class ElectroPharmacy
{
@ -381,8 +381,11 @@
this.ClientSize = new System.Drawing.Size(1089, 554);
this.Controls.Add(this.bindControl);
this.Controls.Add(this.tabContent);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle;
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
this.MaximizeBox = true;
this.MinimizeBox = true;
this.Margin = new System.Windows.Forms.Padding(4);
this.MinimumSize = new System.Drawing.Size(900, 620);
this.Name = "ElectroPharmacy";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Электронная Фармация";

View File

@ -23,6 +23,7 @@ namespace Электронная_Фармация
private ToastService _toastService;
private LoadingOverlay _loadingOverlay;
private ModernAppHeader _appHeader;
private ModernTitleBar _titleBar;
private ModernDocumentTabStrip _documentTabStrip;
private Panel _documentHost;
private Panel _statusFooter;
@ -38,6 +39,9 @@ namespace Электронная_Фармация
Font = new Font("Segoe UI", 12f);
AutoScaleMode = AutoScaleMode.Dpi;
KeyPreview = true;
FormBorderStyle = FormBorderStyle.None;
MaximizeBox = true;
MinimizeBox = true;
MinimumSize = new Size(900, 620);
bindControl.Visible = false;
@ -47,17 +51,22 @@ namespace Электронная_Фармация
AppServices.Initialize(_toastService);
_loadingOverlay = new LoadingOverlay { Name = "loadingOverlay", Dock = DockStyle.Fill };
_titleBar = new ModernTitleBar { Name = "titleBar" };
_titleBar.Attach(this);
_titleBar.ApplyTheme(ThemeManager.Colors);
_appHeader = new ModernAppHeader { Name = "appHeader", Dock = DockStyle.Top };
_appHeader.SetItems(new[]
{
new SidebarItem { Key = "price", Text = "ПРАЙС-ЛИСТ", IconGlyph = "\uE8FD" },
new SidebarItem { Key = "orders", Text = "ЗАКАЗЫ", IconGlyph = "\uE719" },
new SidebarItem { Key = "invoices", Text = "НАКЛАДНЫЕ", IconGlyph = "\uE8A5" },
new SidebarItem { Key = "upload", Text = "ЗАГРУЗИТЬ", IconGlyph = "\uE896" },
new SidebarItem { Key = "send", Text = "ОТПРАВИТЬ", IconGlyph = "\uE725" },
new SidebarItem { Key = "refusals", Text = "ОТКАЗЫ", IconGlyph = "\uE711" },
new SidebarItem { Key = "directory", Text = "СПРАВОЧНИК", IconGlyph = "\uE8FD" },
new SidebarItem { Key = "auth", Text = "АВТОРИЗАЦИЯ", IconGlyph = "\uE192" }
new SidebarItem { Key = "price", Text = "Прайс-лист", IconGlyph = "\uE14C" },
new SidebarItem { Key = "orders", Text = "Заказы", IconGlyph = "\uE7BF" },
new SidebarItem { Key = "invoices", Text = "Накладные", IconGlyph = "\uE8A5" },
new SidebarItem { Key = "reports", Text = "Отчёты", IconGlyph = "" },
new SidebarItem { Key = "upload", Text = "Загрузить", IconGlyph = "\uE896" },
new SidebarItem { Key = "send", Text = "Отправить", IconGlyph = "\uE725" },
new SidebarItem { Key = "refusals", Text = "Отказы", IconGlyph = "\uE7BA" },
new SidebarItem { Key = "directory", Text = "Справочник", IconGlyph = "\uE8F1" },
new SidebarItem { Key = "auth", Text = "Вход", IconGlyph = "\uE192" }
});
_documentTabStrip = new ModernDocumentTabStrip { Name = "documentTabStrip", Dock = DockStyle.Top };
@ -67,16 +76,22 @@ namespace Электронная_Фармация
{
Name = "statusFooter",
Dock = DockStyle.Bottom,
Height = 28,
Height = 32,
Tag = "status-footer",
Padding = new Padding(10, 0, 4, 0)
Padding = new Padding(12, 0, 8, 0)
};
_statusFooter.Paint += (_, e) =>
{
var border = ThemeManager.Colors.Border;
using (var pen = new Pen(border))
var theme = ThemeManager.Colors;
var g = e.Graphics;
using (var fill = new SolidBrush(theme.CartHeaderBackground))
{
e.Graphics.DrawLine(pen, 0, 0, _statusFooter.Width, 0);
g.FillRectangle(fill, _statusFooter.ClientRectangle);
}
using (var pen = new Pen(theme.AccentPrimaryBorder))
{
g.DrawLine(pen, 0, 0, _statusFooter.Width, 0);
}
};
@ -118,6 +133,7 @@ namespace Электронная_Фармация
Controls.Add(_statusFooter);
Controls.Add(_documentTabStrip);
Controls.Add(_appHeader);
Controls.Add(_titleBar);
_directoryMenu = new ContextMenuStrip();
var contractorsItem = new ToolStripMenuItem("Поставщики");
@ -167,12 +183,24 @@ namespace Электронная_Фармация
{
UiThemeHelper.ApplyToControlTree(this);
UpdateThemeToggleState();
_titleBar?.ApplyTheme(ThemeManager.Colors);
WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
};
HandleCreated += (_, __) => WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
}
protected override void WndProc(ref Message m)
{
if (FormBorderStyle == FormBorderStyle.None
&& WindowChromeHelper.TryHandleNcHitTest(this, ref m))
{
return;
}
base.WndProc(ref m);
}
private void UpdateThemeToggleState()
{
if (_documentTabStrip != null)
@ -193,14 +221,49 @@ namespace Электронная_Фармация
{
ThemeManager.CurrentVariant = Settings.Default.UseDarkTheme
? ThemeVariant.DarkProductivity
: ThemeVariant.PharmaTrust;
: ThemeVariant.ClinicalPro;
_themeService.Initialize(this);
UpdateThemeToggleState();
UiThemeHelper.ApplyToControlTree(this);
_titleBar?.ApplyTheme(ThemeManager.Colors);
WindowChromeHelper.ApplyTitleBarTheme(this, ThemeManager.Colors);
try
{
Icon = CreateAppIcon();
}
catch
{
// keep default
}
RefreshCurrentUserStatus();
}
private static Icon CreateAppIcon()
{
using (var bmp = new Bitmap(32, 32))
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
g.Clear(Color.Transparent);
using (var brush = new SolidBrush(Color.FromArgb(15, 118, 110)))
{
g.FillEllipse(brush, 1, 1, 30, 30);
}
using (var pen = new Pen(Color.White, 3f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round
})
{
g.DrawLine(pen, 16, 8, 16, 24);
g.DrawLine(pen, 8, 16, 24, 16);
}
return Icon.FromHandle(bmp.GetHicon());
}
}
private void RefreshCurrentUserStatus()
{
if (_lblCurrentUser == null)
@ -259,6 +322,9 @@ namespace Электронная_Фармация
case "invoices":
OpenInvoicesTab();
break;
case "reports":
OpenReportsTab();
break;
case "upload":
tsBtnDownloadData_Click(this, EventArgs.Empty);
_appHeader.SetActive("upload");
@ -437,6 +503,12 @@ namespace Электронная_Фармация
OpenDocumentTab("Отказы", "refusals", refusals, allowDuplicate: false);
}
private void OpenReportsTab()
{
var reports = new UCReports();
OpenDocumentTab("Отчёты", "reports", reports, allowDuplicate: false);
}
partial void ShowShellLoading(string message)
{
_loadingOverlay?.Show(message);

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
@ -80,7 +80,8 @@ namespace Электронная_Фармация
dlg.ShowDialog(this);
}
Application.Exit();
// Жёстко завершаем процесс, чтобы updater смог заменить exe.
Environment.Exit(0);
return;
}
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
@ -509,76 +509,17 @@ FROM [TempOrderItems];";
}
}
// Перед seed-вставкой грузополучателей нужно гарантировать наличие LocationId,
// иначе INSERT в Consignees (LocationId) упадёт на старых версиях БД.
// LocationId нужен для связи с BuyerLocation на сервере.
TryAddColumn(conForCheckTables, "Consignees", "LocationId", "nvarchar(64)");
// Seed-тестовые грузополучатели (аптеки) для удобства ручного тестирования.
// Вставляем только если таблица пустая.
using (var countConsigneesCmd = new SQLiteCommand(
@"SELECT COUNT(*) FROM Consignees;",
// Удаляем старые тестовые грузополучатели — точки создаются в веб-кабинете.
using (var delTest = new SQLiteCommand(
@"DELETE FROM Consignees
WHERE ConsigneesName LIKE 'Тестовая аптека%'
OR ConsigneesAddress LIKE 'Тестовый адрес%';",
conForCheckTables))
{
var countObj = countConsigneesCmd.ExecuteScalar();
var count = 0;
try { count = Convert.ToInt32(countObj); } catch { count = 0; }
if (count == 0)
{
var testLocationIds = new[]
{
"87368f6d-488b-41a9-b170-edc629522844",
"c4cd845f-820d-45ac-a473-3df1a0b96feb",
"d6187013-00f5-4c1b-b258-c670cf71d115"
};
// Тестовые имена/адреса — можно менять под вашу тестовую среду.
var testNames = new[]
{
"Тестовая аптека 1",
"Тестовая аптека 2",
"Тестовая аптека 3"
};
var testAddresses = new[]
{
"Тестовый адрес 1",
"Тестовый адрес 2",
"Тестовый адрес 3"
};
using (var tx = conForCheckTables.BeginTransaction())
{
int nextCode;
using (var nextCodeCmd = new SQLiteCommand(
@"SELECT ifnull(MAX(codeConsignees), 0) + 1 FROM Consignees;",
conForCheckTables,
tx))
{
nextCode = Convert.ToInt32(nextCodeCmd.ExecuteScalar());
}
for (var i = 0; i < 3; i++)
{
using (var insertCmd = new SQLiteCommand(
@"INSERT INTO Consignees
(codeConsignees, ConsigneesName, ConsigneesAddress, ConsigneesUseAsDefault, LocationId)
VALUES (@code, @name, @address, @isDefault, @loc);",
conForCheckTables,
tx))
{
insertCmd.Parameters.AddWithValue("@code", nextCode + i);
insertCmd.Parameters.AddWithValue("@name", testNames[i]);
insertCmd.Parameters.AddWithValue("@address", testAddresses[i]);
insertCmd.Parameters.AddWithValue("@isDefault", i == 0 ? 1 : 0);
insertCmd.Parameters.AddWithValue("@loc", testLocationIds[i]);
insertCmd.ExecuteNonQuery();
}
}
tx.Commit();
}
}
delTest.ExecuteNonQuery();
}
TryAddColumn(conForCheckTables, "Orders", "ConsigneeName", "nvarchar(128)");
@ -600,7 +541,7 @@ FROM [TempOrderItems];";
{
try
{
var logDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
var logDir = AppConfig.LogsDirectory;
Directory.CreateDirectory(logDir);
var logPath = Path.Combine(
logDir,

View File

@ -1,8 +1,10 @@
using System;
using System.Data;
using System.Data.SQLite;
using System.Threading.Tasks;
using System.Windows.Forms;
using Электроннаяармация.Classes;
using Электроннаяармация.Properties;
namespace Электроннаяармация.Forms
{
@ -15,10 +17,12 @@ namespace Электронная_Фармация.Forms
InitializeComponent();
}
private void FConsignees_Load(object sender, EventArgs e)
private async void FConsignees_Load(object sender, EventArgs e)
{
UiThemeHelper.ApplyToControlTree(this);
btnAdd.Text = "С сервера";
showMeContentFromConsigneesTable();
await SyncFromServerAsync(silent: true);
}
void showMeContentFromConsigneesTable()
@ -49,7 +53,8 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
foreach (DataGridViewColumn column in dgvConsignees.Columns)
{
column.ReadOnly = column.Name != "Location ID" && column.Name != "Адрес";
// Редактируем только адрес локально; Location ID и имя приходят с сервера.
column.ReadOnly = column.Name != "Адрес";
}
}
catch (Exception ex)
@ -88,8 +93,6 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
dgvConsignees.EndEdit();
// Кто должен стать активной аптекой после сохранения.
// По вашему ТЗ: "аптека выбранная в этом окне" => текущая выделенная строка.
var selectedName = dgvConsignees.CurrentRow?.Cells["Наименование"]?.Value?.ToString()?.Trim()
?? string.Empty;
SelectedConsigneeNameAfterSave = selectedName;
@ -108,14 +111,12 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
using (var cmd = new SQLiteCommand(
@"UPDATE Consignees
SET ConsigneesAddress = @address,
LocationId = @locationId
SET ConsigneesAddress = @address
WHERE idConsignees = @id",
connection,
tx))
{
cmd.Parameters.AddWithValue("@address", row["Адрес"]?.ToString() ?? string.Empty);
cmd.Parameters.AddWithValue("@locationId", row["Location ID"]?.ToString()?.Trim() ?? string.Empty);
cmd.Parameters.AddWithValue("@id", Convert.ToInt64(row["Id"]));
cmd.ExecuteNonQuery();
}
@ -137,107 +138,58 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
Close();
}
private void btnAdd_Click(object sender, EventArgs e)
private async void btnAdd_Click(object sender, EventArgs e)
{
using (var dialog = new Form())
await SyncFromServerAsync(silent: false);
}
private async Task SyncFromServerAsync(bool silent)
{
try
{
dialog.Text = "Новая аптека";
dialog.FormBorderStyle = FormBorderStyle.FixedDialog;
dialog.StartPosition = FormStartPosition.CenterParent;
// Немного увеличиваем высоту/ширину на случай DPI/шрифтов.
dialog.ClientSize = new System.Drawing.Size(480, 310);
dialog.MaximizeBox = false;
dialog.MinimizeBox = false;
dialog.ShowInTaskbar = false;
var lblName = new Label { Text = "Наименование", Left = 16, Top = 16, AutoSize = true };
var txtName = new TextBox { Left = 16, Top = 42, Width = 440, Height = 28 };
var lblAddress = new Label { Text = "Адрес", Left = 16, Top = 82, AutoSize = true };
var txtAddress = new TextBox { Left = 16, Top = 108, Width = 440, Height = 28 };
var lblLocation = new Label { Text = "Location ID", Left = 16, Top = 148, AutoSize = true };
var txtLocation = new TextBox { Left = 16, Top = 174, Width = 440, Height = 28 };
var btnOk = new Elfisa.UI.Controls.ModernButton
btnAdd.Enabled = false;
var client = new ApiClient();
if (!client.IsAuthenticated)
{
Text = "Добавить",
DialogResult = DialogResult.OK
};
var btnCancel = new Elfisa.UI.Controls.ModernButton
{
Text = "Отмена",
DialogResult = DialogResult.Cancel
};
dialog.Controls.AddRange(new Control[]
{
lblName, txtName, lblAddress, txtAddress, lblLocation, txtLocation, btnOk, btnCancel
});
dialog.AcceptButton = btnOk;
dialog.CancelButton = btnCancel;
UiThemeHelper.ApplyToControlTree(dialog);
btnOk.Padding = new Padding(12, 4, 12, 4);
btnCancel.Padding = new Padding(12, 4, 12, 4);
Elfisa.UI.Controls.ModernButtonStyles.FitToText(btnOk, 36);
Elfisa.UI.Controls.ModernButtonStyles.FitToText(btnCancel, 36);
btnOk.Height = 36;
btnCancel.Height = 36;
const int padding = 16;
const int gap = 10;
// Минимально гарантируем ширину, чтобы обе кнопки поместились.
var requiredWidth = btnOk.Width + btnCancel.Width + gap + padding * 2;
if (dialog.ClientSize.Width < requiredWidth)
{
dialog.ClientSize = new System.Drawing.Size(requiredWidth, dialog.ClientSize.Height);
}
// Всегда зажимаем в границы формы после вычисления ширины ModernButton'ов.
btnCancel.Left = Math.Max(padding, dialog.ClientSize.Width - btnCancel.Width - padding);
btnCancel.Top = dialog.ClientSize.Height - btnCancel.Height - padding;
btnOk.Left = Math.Max(padding, btnCancel.Left - btnOk.Width - gap);
btnOk.Top = btnCancel.Top;
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
var name = (txtName.Text ?? string.Empty).Trim();
var address = (txtAddress.Text ?? string.Empty).Trim();
var locationId = (txtLocation.Text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(name))
{
UiDialogs.ShowError("Укажите наименование аптеки.", "Грузополучатели", this);
return;
}
using (var connection = new SQLiteConnection(AppConfig.SqliteConnectionString))
{
connection.Open();
int nextCode = 1;
using (var codeCmd = new SQLiteCommand("SELECT ifnull(MAX(codeConsignees), 0) + 1 FROM Consignees", connection))
var token = Settings.Default.stringToken;
if (string.IsNullOrWhiteSpace(token))
{
nextCode = Convert.ToInt32(codeCmd.ExecuteScalar());
}
using (var insert = new SQLiteCommand(
@"INSERT INTO Consignees (codeConsignees, ConsigneesName, ConsigneesAddress, ConsigneesUseAsDefault, LocationId)
VALUES (@code, @name, @address, 0, @locationId)",
connection))
{
insert.Parameters.AddWithValue("@code", nextCode);
insert.Parameters.AddWithValue("@name", name);
insert.Parameters.AddWithValue("@address", address);
insert.Parameters.AddWithValue("@locationId", locationId);
insert.ExecuteNonQuery();
if (!silent)
{
UiDialogs.ShowInfo(
"Сначала войдите на сервер — грузополучатели подтягиваются из веб-кабинета покупателя.",
"Грузополучатели",
this);
}
return;
}
client.SetToken(token);
}
ToastNotification.ShowSuccess("Аптека добавлена");
var locations = await client.GetBuyerLocationsAsync();
var count = ConsigneeHelper.SyncFromServer(locations);
showMeContentFromConsigneesTable();
if (!silent)
{
ToastNotification.ShowSuccess(count > 0
? $"Обновлено грузополучателей: {count}"
: "На сервере пока нет грузополучателей");
}
}
catch (Exception ex)
{
if (!silent)
{
UiDialogs.ShowError($"Не удалось загрузить грузополучателей с сервера.\n{ex.Message}", "Грузополучатели", this);
}
else
{
AppDebugLog.Error("FConsignees", "Тихая синхронизация грузополучателей не удалась", ex);
}
}
finally
{
btnAdd.Enabled = true;
}
}
@ -249,15 +201,15 @@ order by [ConsigneesUseAsDefault] desc, [ConsigneesName]";
return;
}
var name = dgvConsignees.CurrentRow.Cells["Наименование"]?.Value?.ToString();
var name = dgvConsignees.CurrentRow.Cells["Наименование"]?.Value?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
return;
}
ConsigneeHelper.SetActiveConsignee(name);
ToastNotification.ShowSuccess($"Аптека по умолчанию: {name}");
showMeContentFromConsigneesTable();
ToastNotification.ShowSuccess($"Аптека по умолчанию: {name}");
}
}
}

View File

@ -54,6 +54,21 @@ namespace Электронная_Фармация.HelpForms
SetOverallProgress(1, "Авторизация...");
await EnsureAuthenticatedAsync(client);
try
{
AppendStatus("Загружаю грузополучателей с сервера...");
var locations = await client.GetBuyerLocationsAsync();
var synced = ConsigneeHelper.SyncFromServer(locations);
AppendStatus(synced > 0
? $"Грузополучателей обновлено: {synced}"
: "Грузополучателей на сервере пока нет");
}
catch (Exception locEx)
{
AppendStatus($"Грузополучатели не обновлены: {locEx.Message}");
AppDebugLog.Error("Download", "Не удалось синхронизировать грузополучателей", locEx);
}
SetOverallProgress(3, "Загрузка прайса с сервера...");
AppendStatus("Загружаю сводный прайс...");
var allSuppliersSummary = await GetFullPriceSummaryPagedAsync(client);

View File

@ -53,8 +53,11 @@ namespace Электронная_Фармация.HelpForms
{
await AutoUpdater.ApplyUpdateAsync(_result, progress);
lblMessage.Text = "Обновление скачано. Перезапуск...";
// Снимаем блокировку закрытия, иначе диалог зависает навечно
// на «Перезапуск...» и updater ждёт PID, который не завершается.
_updating = false;
DialogResult = DialogResult.OK;
Application.Exit();
Close();
}
catch (Exception ex)
{
@ -84,7 +87,8 @@ namespace Электронная_Фармация.HelpForms
private void HF_UpdateAvailable_FormClosing(object sender, FormClosingEventArgs e)
{
if (_updating)
// Блокируем только ручное закрытие во время скачивания.
if (_updating && DialogResult != DialogResult.OK)
{
e.Cancel = true;
return;

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Windows.Forms;
using System.Net;
using Электроннаяармация.Classes;

View File

@ -1,36 +1,37 @@
using System.Reflection;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// Общие сведения об этой сборке предоставляются следующим набором
// набора атрибутов. Измените значения этих атрибутов для изменения сведений,
// связанных со сборкой.
[assembly: AssemblyTitle("Электронная Фармация")]
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ. пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ,
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ.
[assembly: AssemblyTitle("пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Электронная Фармация")]
[assembly: AssemblyCopyright("Copyright © 2023")]
[assembly: AssemblyProduct("пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ")]
[assembly: AssemblyCopyright("Copyright пїЅ 2023")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Установка значения False для параметра ComVisible делает типы в этой сборке невидимыми
// для компонентов COM. Если необходимо обратиться к типу в этой сборке через
// COM, следует установить атрибут ComVisible в TRUE для этого типа.
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ False пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ ComVisible пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ COM. пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ
// COM, пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ ComVisible пїЅ TRUE пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ.
[assembly: ComVisible(false)]
// Следующий GUID служит для идентификации библиотеки типов, если этот проект будет видимым для COM
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ GUID пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ, пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ COM
[assembly: Guid("145d8060-4187-4871-b4fb-8df795727b66")]
// Сведения о версии сборки состоят из указанных ниже четырех значений:
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ:
//
// Основной номер версии
// Дополнительный номер версии
// Номер сборки
// Редакция
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
//
// Можно задать все значения или принять номера сборки и редакции по умолчанию
// используя "*", как показано ниже:
// пїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅ пїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ
// пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ "*", пїЅпїЅпїЅ пїЅпїЅпїЅпїЅпїЅпїЅпїЅпїЅ пїЅпїЅпїЅпїЅ:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.13.0")]
[assembly: AssemblyFileVersion("1.0.13.0")]
[assembly: AssemblyVersion("1.0.29.0")]
[assembly: AssemblyFileVersion("1.0.29.0")]

View File

@ -1,4 +1,4 @@
//------------------------------------------------------------------------------
//------------------------------------------------------------------------------
// <auto-generated>
// Этот код создан программой.
// Исполняемая версия:4.0.30319.42000
@ -131,6 +131,18 @@ namespace Электронная_Фармация.Properties {
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("0")]
public int PriceListSplitterDistance {
get {
return ((int)(this["PriceListSplitterDistance"]));
}
set {
this["PriceListSplitterDistance"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]

View File

@ -1,4 +1,4 @@
<?xml version='1.0' encoding='utf-8'?>
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="Электронная_Фармация.Properties" GeneratedClassName="Settings">
<Profiles />
<Settings>
@ -29,6 +29,9 @@
<Setting Name="UseDarkTheme" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
<Setting Name="PriceListSplitterDistance" Type="System.Int32" Scope="User">
<Value Profile="(Default)">0</Value>
</Setting>
<Setting Name="InvoiceExportPath" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -37,7 +37,7 @@ namespace Электронная_Фармация.UserControls
dtpDateFrom.Value = DateTime.Now.AddDays(-3);
dtpDateTo.Value = DateTime.Now;
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), string.Empty);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), string.Empty);
loadSuppliers();
comboSuppliers.Visible = true;
@ -334,8 +334,8 @@ namespace Электронная_Фармация.UserControls
var addon = parts.Count == 0 ? string.Empty : " and " + string.Join(" and ", parts);
LoadOrders(
dtpDateFrom.Value.ToString("dd.MM.yyyy"),
dtpDateTo.Value.ToString("dd.MM.yyyy"),
dtpDateFrom.Value.ToString("yyyy-MM-dd"),
dtpDateTo.Value.ToString("yyyy-MM-dd"),
addon);
}
@ -411,6 +411,14 @@ namespace Электронная_Фармация.UserControls
{
string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
// OrderDate в БД исторически dd.MM.yyyy — сравниваем как даты, не как строки,
// иначе «03.08» не попадает в диапазон «с 31.07» (лексикографически).
const string orderDateIso = @"CASE
WHEN length(OrderDate)=10 AND substr(OrderDate,3,1)='.' THEN
substr(OrderDate,7,4)||'-'||substr(OrderDate,4,2)||'-'||substr(OrderDate,1,2)
ELSE OrderDate
END";
string qShowMeOrders = $@"
select
[Id_Order],
@ -425,8 +433,8 @@ select
[ConsigneeName]
from Orders
where
OrderDate >= '{dateFrom}'
and OrderDate <= '{dateTo}'
({orderDateIso}) >= '{dateFrom}'
and ({orderDateIso}) <= '{dateTo}'
{filters}
";
@ -684,25 +692,25 @@ order by SupplierName asc";
{
txtGoodFromOrders.Text = "Товар";
filter = string.Empty;
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
private void dtpDateFrom_ValueChanged(object sender, EventArgs e)
{
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
private void dtpDateTo_ValueChanged(object sender, EventArgs e)
{
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
private void dtpDateTo_KeyPress(object sender, KeyPressEventArgs e)
{
if (e.KeyChar == (Char)Keys.Enter)
{
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
}
@ -710,7 +718,7 @@ order by SupplierName asc";
{
if (e.KeyChar == (Char)Keys.Enter)
{
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
}
@ -734,7 +742,7 @@ order by SupplierName asc";
and Id_Order in (select Id_Order from OrderItems where DrugName like '%{txtGoodFromOrders.Text}%')
";
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
}
}
@ -881,7 +889,7 @@ where [id_order] = '{idPriceList}'
and OrderNumber like '%{txtOrderNumberFilter.Text}%'
";
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
}
}
@ -906,7 +914,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
{
txtOrderNumberFilter.Text = "Номер заказа";
filter = string.Empty;
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), filter);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), filter);
}
private void btnAddComment_Click(object sender, EventArgs e)
@ -919,7 +927,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
HF_Comment hf_comment = new HF_Comment(OrderNumber, Id_Order);
hf_comment.ShowDialog();
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), string.Empty);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), string.Empty);
}
}
@ -956,7 +964,7 @@ and OrderNumber like '%{txtOrderNumberFilter.Text}%'
//ToastNotification.ShowCustom($"Заказ {OrderNumber} был удалён", Color.DarkOrange, Color.White);
ToastNotification.ShowSuccess($"Заказ {OrderNumber} был удалён");
//MessageBox.Show($"Заказ {OrderNumber} был успешно удалён.", "Удаление заказа", MessageBoxButtons.OK, MessageBoxIcon.Information);
LoadOrders(dtpDateFrom.Value.ToString("dd.MM.yyyy"), dtpDateTo.Value.ToString("dd.MM.yyyy"), string.Empty);
LoadOrders(dtpDateFrom.Value.ToString("yyyy-MM-dd"), dtpDateTo.Value.ToString("yyyy-MM-dd"), string.Empty);
}
catch(Exception ex)
{

View File

@ -554,8 +554,9 @@ group by SupplierName
void createOrderForNextSuppliers(string SupName)
{
string OrderNumber = $"EP-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
string OrderDate = DateTime.Now.ToString("dd.MM.yyyy");
// Глобальный номер EX-0000001… (сервер / локальный fallback).
string OrderNumber = OrderNumberHelper.AllocateForNewOrder();
string OrderDate = DateTime.Now.ToString("yyyy-MM-dd");
_consigneeName = ParentForm?.ConsigneeName ?? _consigneeName ?? string.Empty;
var consigneeSafe = (_consigneeName ?? string.Empty).Replace("'", "''");
var supNameSafe = (SupName ?? string.Empty).Replace("'", "''");

View File

@ -1,4 +1,4 @@
namespace Электроннаяармация.UserControls
namespace Электроннаяармация.UserControls
{
partial class UCPriceList
{
@ -99,7 +99,7 @@
this.SplitPriceList.Panel2.Controls.Add(this.POrderInfo);
this.SplitPriceList.Size = new System.Drawing.Size(1053, 532);
this.SplitPriceList.SplitterDistance = 290;
this.SplitPriceList.SplitterWidth = 2;
this.SplitPriceList.SplitterWidth = 6;
this.SplitPriceList.TabIndex = 0;
//
// TLPPriceList

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
@ -78,13 +78,14 @@ namespace Электронная_Фармация.UserControls
private void UCPriceList_Load(object sender, EventArgs e)
{
EnsureBrowseSplit();
EnsureChromeLayout();
UiThemeHelper.ApplyToControlTree(this);
Tag = "chrome-child";
AlignSearchRow();
LayoutContentBelowSearch();
panel1.SizeChanged += (_, __) => LayoutMarkupRow();
LayoutMarkupRow();
SizeChanged += (_, __) => LayoutContentBelowSearch();
txtConsignee.Enabled = false;
txtConsignee.Text = _WorkWithConsignee;
@ -97,6 +98,120 @@ namespace Электронная_Фармация.UserControls
txtSearchGoodNameInPriceList.Focus();
}
/// <summary>
/// Поиск остаётся сверху как в оригинале; контент явно ниже — без Dock-наложения.
/// </summary>
private void EnsureChromeLayout()
{
// Убираем старую dock-панель, если осталась от прошлых сборок в той же сессии.
var oldToolbar = Controls["priceToolbar"];
if (oldToolbar != null)
{
var toRestore = oldToolbar.Controls.Cast<Control>().ToArray();
foreach (var control in toRestore)
{
oldToolbar.Controls.Remove(control);
Controls.Add(control);
control.BringToFront();
}
Controls.Remove(oldToolbar);
oldToolbar.Dispose();
}
// Строка наценки скрыта — проценты берутся из настроек/дефолта без UI-прослойки.
if (TLPPriceList.RowStyles.Count >= 2)
{
TLPPriceList.RowStyles[1].SizeType = SizeType.Absolute;
TLPPriceList.RowStyles[1].Height = 0;
}
panel1.Visible = false;
panel1.Height = 0;
SplitPriceList.Panel1MinSize = 120;
SplitPriceList.Panel2MinSize = 80;
SplitPriceList.IsSplitterFixed = false;
SplitPriceList.FixedPanel = FixedPanel.None;
SplitPriceList.SplitterWidth = 6;
// Восстановить положение сплиттера корзины, если пользователь уже двигал.
RestoreCartSplitterDistance();
SplitPriceList.SplitterMoved += (_, __) => PersistCartSplitterDistance();
dgvPriceList.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None;
dgvPriceList.ColumnHeadersVisible = true;
dgvPriceList.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing;
if (dgvPriceList.ColumnHeadersHeight < 34)
{
dgvPriceList.ColumnHeadersHeight = 36;
}
if (!POrderInfo.Controls.ContainsKey("cartHeader"))
{
var cartHeader = new ModernSectionHeader
{
Name = "cartHeader",
Height = 32,
HeaderText = "Корзина"
};
cartHeader.ApplyTheme(Elfisa.UI.Theming.ThemeManager.Colors, cartStyle: true);
cartHeader.Dock = DockStyle.Top;
POrderInfo.Controls.Add(cartHeader);
tabOrderItems.SendToBack();
cartHeader.BringToFront();
}
}
private void LayoutContentBelowSearch()
{
const int searchBand = 52;
PContentPriceList.Dock = DockStyle.None;
PContentPriceList.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
PContentPriceList.SetBounds(
0,
searchBand,
Math.Max(100, ClientSize.Width),
Math.Max(120, ClientSize.Height - searchBand));
}
private void RestoreCartSplitterDistance()
{
try
{
var saved = Properties.Settings.Default.PriceListSplitterDistance;
if (saved <= 0 || SplitPriceList.Height < 200)
{
return;
}
var max = Math.Max(
SplitPriceList.Panel1MinSize,
SplitPriceList.Height - SplitPriceList.Panel2MinSize - SplitPriceList.SplitterWidth);
SplitPriceList.SplitterDistance = Math.Max(SplitPriceList.Panel1MinSize, Math.Min(saved, max));
}
catch
{
// ignore until layout ready
}
}
private void PersistCartSplitterDistance()
{
try
{
if (SplitPriceList.SplitterDistance > 80)
{
Properties.Settings.Default.PriceListSplitterDistance = SplitPriceList.SplitterDistance;
Properties.Settings.Default.Save();
}
}
catch
{
// ignore
}
}
private void EnsureBrowseSplit()
{
if (splitNameMgBrowse != null)
@ -112,13 +227,35 @@ namespace Электронная_Фармация.UserControls
Name = "splitNameMgBrowse",
Dock = DockStyle.Fill,
Orientation = Orientation.Vertical,
SplitterWidth = 4,
Visible = false
SplitterWidth = 6,
Visible = false,
Panel1MinSize = 100,
Panel2MinSize = 80,
IsSplitterFixed = false,
FixedPanel = FixedPanel.None
};
splitNameMgBrowse.Panel1.Controls.Add(dgvBrowseNames);
splitNameMgBrowse.Panel2.Controls.Add(dgvBrowseMg);
TLPPriceList.Controls.Add(splitNameMgBrowse, 0, 0);
var gridHost = TLPPriceList.Controls["priceGridHost"] as Panel;
if (gridHost == null)
{
gridHost = new Panel
{
Name = "priceGridHost",
Dock = DockStyle.Fill,
Margin = new Padding(0),
Padding = new Padding(0),
Tag = "chrome-child"
};
TLPPriceList.Controls.Remove(dgvPriceList);
dgvPriceList.Dock = DockStyle.Fill;
gridHost.Controls.Add(dgvPriceList);
TLPPriceList.Controls.Add(gridHost, 0, 0);
}
gridHost.Controls.Add(splitNameMgBrowse);
splitNameMgBrowse.BringToFront();
dgvBrowseNames.SelectionChanged += dgvBrowseNames_SelectionChanged;
@ -157,11 +294,13 @@ namespace Электронная_Фармация.UserControls
private void AlignSearchRow()
{
const int searchY = 9;
const int searchY = 10;
const int searchHeight = 32;
const int clearGap = 2;
const int clearGap = 4;
const int leftPad = 10;
txtSearchGoodNameInPriceList.SetBounds(78, searchY, 313, searchHeight);
label2.Location = new Point(leftPad, searchY + 4);
txtSearchGoodNameInPriceList.SetBounds(label2.Right + 6, searchY, 300, searchHeight);
btnClearSearchInPriceList.SetBounds(
txtSearchGoodNameInPriceList.Right + clearGap,
searchY,
@ -169,38 +308,23 @@ namespace Электронная_Фармация.UserControls
searchHeight);
ModernButtonStyles.ApplyClear(btnClearSearchInPriceList);
lblSupplierFilter.Location = new Point(btnClearSearchInPriceList.Right + 16, searchY + 4);
comboSupplierFilter.SetBounds(lblSupplierFilter.Right + 6, searchY, 240, searchHeight);
btnClearSupplierFilter.SetBounds(comboSupplierFilter.Right + clearGap, searchY, 32, searchHeight);
btnClearSupplierFilter.Height = searchHeight;
// Поиск всегда поверх контента, но контент начинается ниже searchBand.
label2.BringToFront();
txtSearchGoodNameInPriceList.BringToFront();
btnClearSearchInPriceList.BringToFront();
lblSupplierFilter.BringToFront();
comboSupplierFilter.BringToFront();
btnClearSupplierFilter.BringToFront();
}
private void LayoutMarkupRow()
{
// Keep markup row readable at different DPI/window sizes.
try
{
var paddingLeft = 8;
var y = 30;
var gap = 8;
label4.Location = new Point(paddingLeft, y + 2);
numericPercent.Location = new Point(label4.Right + 6, y);
numericPercent.Width = 56;
checkUsePercentageAsDefault.AutoSize = true;
checkUsePercentageAsDefault.Location = new Point(
Math.Max(numericPercent.Right + gap, panel1.ClientSize.Width - checkUsePercentageAsDefault.PreferredSize.Width - 10),
y + 2);
lblPriceForGood.AutoSize = false;
lblPriceForGood.AutoEllipsis = true;
lblPriceForGood.Location = new Point(numericPercent.Right + gap, y + 2);
lblPriceForGood.Height = 22;
lblPriceForGood.Width = Math.Max(90, checkUsePercentageAsDefault.Left - lblPriceForGood.Left - gap);
}
catch
{
// best-effort layout; ignore
}
// UI наценки скрыт; метод оставлен на случай повторного включения.
}
private void InitializeSupplierFilter()
@ -725,10 +849,21 @@ namespace Электронная_Фармация.UserControls
{
EnsureBrowseSplit();
var gridHost = TLPPriceList.Controls["priceGridHost"] as Panel;
if (gridHost != null)
{
gridHost.Dock = DockStyle.Fill;
if (gridHost.Padding.Top < 8)
{
gridHost.Padding = new Padding(0, 8, 0, 0);
}
}
if (browseVisible)
{
dgvPriceList.Visible = false;
splitNameMgBrowse.Visible = true;
splitNameMgBrowse.Dock = DockStyle.Fill;
splitNameMgBrowse.BringToFront();
var width = Math.Max(200, splitNameMgBrowse.Width);
@ -754,6 +889,9 @@ namespace Электронная_Фармация.UserControls
splitNameMgBrowse.Visible = false;
dgvPriceList.Visible = true;
dgvPriceList.Dock = DockStyle.Fill;
dgvPriceList.ColumnHeadersVisible = true;
dgvPriceList.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None;
dgvPriceList.BringToFront();
}
}
@ -1383,7 +1521,7 @@ BestBefore = '{BestBefore}'
{
string connectionStringToLocalDB = AppConfig.SqliteConnectionString;
string OrderNumber = $"EP-{DateTime.Now.Millisecond.ToString()}{DateTime.Now.Date.ToString("mmdd")}";
string OrderNumber = OrderNumberHelper.AllocateForNewOrder();
string OrderDate = DateTime.Now.ToString("yyyy-MM-dd");//DateTime.Now.ToShortDateString();

View 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}";
}
}
}

View 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>

View 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View 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>

View 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();
}

View 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).

View 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>

View 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;
}
}

View 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();
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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 = "Черновик удалён.";
}
}

View 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;
}
}

View 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; }
}
}

View 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 { /* открыть не удалось — путь показан в статусе */ }
}
}

View 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);
}
}

View 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; }
}
}

View 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 { /* локации не критичны для настроек */ }
}
}

View 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();
}

View File

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Elfisa.Avalonia.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View 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>

View 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);
}

View 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>

View 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);
}

View 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>

View 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);
}

View 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>

View 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);
}

View 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>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Elfisa.Avalonia.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View 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>

View 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);
}

View 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>

View 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);
}

View 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>

View 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;
}
}

View 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>

View 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
})
});
}
}
}

View 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>

View 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);
}

View 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>

View 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();
}

View 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>

View 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();
}
}

View 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>

View 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) { }
}

View 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('/');
}
}
}

View 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
View 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; }
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View 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;
}

View 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;
}
}

Some files were not shown because too many files have changed in this diff Show More