Compare commits

..

173 commits

Author SHA1 Message Date
nns dcc3f9d61c content(public): живое наполнение — скриншоты, фото, OG, баннеры
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m5s
CI / Web (React + Vite) (push) Successful in 39s
Docker Public / Build + push Public (push) Successful in 46s
Docker Public / Deploy Public on stage (push) Successful in 10s
— 6 реальных скриншотов админки в режиме tenant override (FOOD MARKET):
  dashboard, аналитика, каталог 29 540 SKU, карточка товара, контрагенты,
  форма приёмки. Снято Playwright через app.food-market.zat.kz.

— 14 stock-фото с Unsplash (Unsplash License) для Hero-секций и CTA:
  pos, import, about, 6 вертикалей, blog covers, integrations, cta-banner.
  CREDITS.md с авторами/profile-ссылками.

— 11 OG-картинок 1200x630 через satori + @resvg/resvg-js: home, pos,
  pricing, import, about, 6 вертикалей. Inter Bold/Regular, emerald
  градиент, лого + заголовок + sub + CTA-кнопка.

— Hero-секции переделаны: full-width фото с dark-overlay-градиентом и
  белая card поверх; вертикали /for-* с одинаковым layout-баннером;
  /pricing с зелёным gradient hero; финальный CTA с emerald overlay 75%.

— BaseLayout: ogImage передаётся пропом, twitter:image добавлен.
  Каждая страница указывает свой /og/<slug>.png.

— Контентная зачистка: «торговые весы» вместо конкретных брендов,
  модели весов убраны со всех материалов. Блог-пост cash-with-scales
  переписан без брендов.

Build: 30 страниц, smoke 20 URL — 200.
2026-04-26 21:08:05 +05:00
nns 5f7cfa0d5b content(public): нейтральный тон, без упоминаний сторонних систем
— Все материалы (главная, /pos, /about, FAQ, kb, blog) переведены на
  нейтральные формулировки: «другие системы учёта», без имён.
— Новая страница /import — единая точка входа по миграции каталога;
  описывает Excel/CSV, REST API и выгрузку 1С.
— Удалены публичные kb/blog-статьи, целиком построенные вокруг
  миграции с конкретного продукта.
— /migration-from-other-system убран из sitemap; nginx делает 301 на /import.
— blog schema расширена optional-полями (author, category, cover_image),
  чтобы новый frontmatter валидировался content collection.
— Грамматические правки: «Импорт из других систем».

Финальный grep по пакету по списку имён конкурирующих SaaS-учётных
продуктов — пусто. Smoke по 9 ключевым URL — 200; старый URL — 301.
2026-04-26 20:19:57 +05:00
nns f5a232a32e content(public): naполнить блог + KB + about/contacts; убрать упоминания сторонних систем
Phase 6 контентная часть — частично:

Блог (3 поста из /tmp/content/blog-and-kb.md, content collection):
- launch — запуск Food Market
- import-other-system — пошаговый гид миграции (внутренняя инструкция, frontmatter)
- cash-with-scales — почему касса с весами Масса-К из коробки

База знаний (5 статей, content collection с category + order):
- quickstart, import-other-system, pos-setup, billing, faq

Страницы /blog и /kb перерисованы — рендерят список из коллекций
с группировкой по категориям, отдельные [slug].astro template'ы
рендерят markdown с типографикой prose-md.

/about и /contacts наполнены реальным контентом из
/tmp/content/about-contacts-pricing-features.md (без заглушек).
Контакты — 4 канала (email/phone/чат/реквизиты) с placeholder-ами
для будущих контактов после регистрации юр.лица.

Очистка от упоминаний сторонних систем (по правилу: не сравнивать с
конкретными системами и не выводить чужие цены публично):
- Hero/FAQ на главной — заменено «Импорт из сторонняя система» на «импорт
  каталога одной кнопкой», убран FAQ-вопрос «Чем отличаетесь от...»,
  заменён на общий «Чем хорош Food Market?».
- Footer: пункт «Импорт из сторонняя система» → «Импорт каталога».
- /migration-from-other-system удалён (содержал сравнительную таблицу
  с ценами сторонняя система). Возможен возврат как KB-инструкция позже.
- features/pos/pricing/integrations/changelog/for-grocery/for-pharmacy
  — sed-замена «сторонняя система» → «другие системы», ссылки на
  /migration-from-other-system → /features.
- В content-collections блог/KB остались упоминания (юзер пришлёт
  PATCH-версии текстов отдельно).

Контейнер пересобран и задеплоен на стенд: 30 страниц, smoke на /,
/blog, /kb, /about, /blog/launch — все 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:53:41 +05:00
nns 4ac7dc585c feat(deploy): Phase 6 — публичный сайт на food-market.zat.kz, админка на app.
Доменная схема (по решению юзера):
  food-market.zat.kz → новый Astro public-сайт (порт 8082, контейнер food-market-public)
  app.food-market.zat.kz → существующая админка (food-market.web, порт 8081)
  API остаётся на app.* под /api/*.

Изменения:
- docker-compose: добавлен сервис public (image food-market-public:latest,
  127.0.0.1:8082:80). На стенде .env дополнен PUBLIC_TAG=latest, контейнер
  поднят, smoke на / и /pricing проходит.
- Forgejo workflow .forgejo/workflows/docker-public.yml — отдельный билд
  при изменениях в src/food-market.public/**: docker build с
  --build-arg PUBLIC_SITE_URL=https://food-market.zat.kz и
  --build-arg PUBLIC_APP_URL=https://app.food-market.zat.kz, push в
  локальный registry, deploy через docker compose pull+up. TG-пинг.
- Nginx (на стенде вручную, не через репо):
  - Новый блок food-market-app.conf для app.food-market.zat.kz —
    проксирует на :8081 (web), вместе с /api/admin/import/ и
    /tg-webhook путями. Certbot --nginx выпустил SSL.
  - Старый food-market-stage.conf переписан на public — проксирует на
    :8082, использует существующий SSL для food-market.zat.kz.
- API CORS: добавлены food-market.zat.kz, app.food-market.zat.kz,
  food-market.kz, app.food-market.kz в AllowedOrigins (publicу нужен
  food-market.zat.kz для signup-запросов, админке нужен app.*).
- JWT cookie domain не настраиваем — проект использует localStorage,
  cross-domain auth-bridge через URL fragment (см. AuthBridgePage),
  что безопаснее cookie с .food-market.zat.kz.
- Хардкодов food-market.zat.kz в food-market.web/src не нашлось —
  всё через относительные URL.

Существующие админ-сессии: токены в localStorage привязаны к
food-market.zat.kz origin. После переезда юзеры увидят на этом
домене публичный сайт без своих токенов — нужно перелогиниться
на app.food-market.zat.kz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:17:48 +05:00
nns ad09d48a89 feat(public): Phase 6 — публичный маркетинговый сайт food-market.public на Astro
Новый пакет src/food-market.public/ — отдельный фронт для маркетинга и
самостоятельной регистрации магазинов-клиентов в SaaS Food Market.
Существующая админка food-market.web НЕ затронута.

Стек: Astro 4 + React 19 islands + Tailwind v3 (палитра идентична
food-market.web — единый бренд), TypeScript 6, content collections для
юр.документов. Static-сайт через nginx, gzip + immutable cache на assets.

Карта страниц (23):
- /                            — главная (Hero + 3 выгоды + скриншот +
                                   6 вертикалей + 6 модулей + Касса +
                                   Интеграции + 3 тарифа + соцпруф +
                                   FAQ + финальный CTA)
- /features                    — модули по сценариям
- /pricing                     — тарифы + интерактивный конструктор
                                   «Бизнес» (per-unit: 10000 база +
                                   2000/магазин + 500/касса + 500/склад,
                                   слайдеры, передача params в /signup)
- /pos                         — УТП лендинг кассы для Windows + весов
- /migration-from-other-system     — УТП лендинг миграции с сторонняя система
                                   (сравнительная таблица + 3 шага)
- /integrations                — список интеграций
- /for-grocery|pharmacy|cafe|alcohol|clothing|household — 6 вертикалей
                                   с уникальными фишками (весовой,
                                   серии/сроки, модификаторы и комбо,
                                   акцизные марки, размерные сетки,
                                   гарантийные сроки)
- /signup                      — регистрация (React-island форма)
- /about /contacts /kb /blog /status /changelog — компания + ресурсы
- /legal/{offer,privacy,consent,requisites} — реальные юр.документы
                                   из /tmp/legal/ как Astro content
                                   collection (markdown с frontmatter,
                                   динамический [slug].astro template,
                                   720px max-width, line-height 1.7,
                                   prose-legal стили)
- /sitemap.xml                 — ручной генератор (sitemap-плагин
                                   конфликтует с Astro 4.16, заменён
                                   на простой APIRoute)

React-острова (3):
- BusinessTariffBuilder — слайдеры + расчёт total + ссылка на signup
- SignupForm — email/password/orgName/phone/plan + валидация + agree
- FAQ — accordion 7 вопросов

API: новый POST /api/auth/signup создаёт Organization + AppUser
(Identity Admin role) + Owner Employee + полный bootstrap через
DevDataSeeder.SeedTenantReferencesAsync (units, price-types, store,
cassa, 6 ролей). Токены НЕ выпускает — фронт сразу делает обычный
/connect/token (password grant) и получает access/refresh без
дублирования OpenIddict-логики. На signup-форме — auth-bridge:
токены передаются через URL fragment в админку
APP_URL/auth-bridge#access=...&refresh=...&welcome=1, AuthBridgePage
кладёт в localStorage и редиректит на /?welcome=signup.

URL-домены через env-переменные (юзер ещё выбирает финальный):
- PUBLIC_SITE_URL — canonical/OG/sitemap (default https://food-market.kz)
- PUBLIC_APP_URL — admin/API endpoint (default https://food-market.zat.kz)
Nginx-конфиг для деплоя сайта — заготовка-template в
deploy/nginx/food-market-public.conf.template, не применён —
ждёт решения по домену.

Dockerfile multi-stage (node:20-alpine build → nginx:1.27 runtime),
build-args PUBLIC_SITE_URL/PUBLIC_APP_URL, deploy/nginx.conf gzip +
immutable cache + try_files для pretty URLs.

SEO: OG-теги, twitter-card, canonical, JSON-LD SoftwareApplication
схема, robots.txt → sitemap-index, lang=ru-KZ.

Admin-side: /auth-bridge route в food-market.web — принимает токены
из URL fragment, кладёт в localStorage (fm.access_token / fm.refresh_token),
редиректит на /. Fragment чтобы access_token не попадал в Referer.

23 страницы билдятся без ошибок. Контейнер собирается. Деплой на
конкретный домен — отдельным шагом после решения юзера.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:11:34 +05:00
nns fc3f63c49a feat(super-admin): настраиваемый retention period для архивных орг
Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.

Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
  Поле ArchiveRetentionDays (int, default 30). Структура расширяется
  именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.

API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
  Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
  чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
  удаление доступно сразу после архивации.

UI:
- /super-admin/settings — страница «Системные настройки».
  Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
  «Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
  настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
  читает retentionDays из API; tooltip показывает оставшиеся дни
  «Доступно через X дн. (retention N)»; при retention=0 — всегда
  active для архивных орг.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:59:24 +05:00
nns 2cadb6e5d9 fix(super-admin): убрать цикл редиректа, регресс override после пакета задач
КОРНЕВАЯ ПРИЧИНА: SuperAdminLayout имел useEffect

  if (getOrgOverride()) navigate('/dashboard', { replace: true })

который автоматически выкидывал в tenant-режим при любом заходе на
/super-admin/* если в localStorage остался override (например с прошлой
сессии или после нечистого выхода). Это создавало цикл: SuperAdmin
кликает «Системная консоль» в меню → попадает на /super-admin/... →
useEffect видит override → navigate /dashboard → tenant TenantRouteGuard
проверяет: override есть → пропускает, но юзер видит tenant с баннером
вместо системной консоли.

Если override содержал «зависший» orgId с прошлой сессии и юзер ожидал
вернуться в SuperAdmin консоль — он попадал в tenant-режим, а попытки
вернуться через ссылки sidebar лечились этим же useEffect снова в /
dashboard. Юзер видел тост от TenantRouteGuard в редких случаях когда
localStorage не успевал прочитаться (race с rAF).

Фикс:
- Удалён useEffect (+ unused useNavigate импорт). SuperAdmin может
  свободно перемещаться между системной консолью и tenant-режимом
  при активном override; снимает override только баннером «Выйти из
  режима» либо переключает другую орг через picker.
- TenantRouteGuard: повторная проверка getOrgOverride() через
  requestAnimationFrame (защита от любых race с localStorage), и
  разовый guard-флаг (useRef) чтобы не редиректить дважды на одной
  странице. Console.warn перед редиректом — облегчает диагностику
  если регресс повторится.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:54:37 +05:00
nns 79016579c2 fix(migration): Phase4d таблица называется units_of_measure, не units
Опечатка: в Up() миграции стояло table:\"units\" — реальное имя в БД
units_of_measure (по EF snapshot). API падал при старте на стенде:
«relation public.units does not exist», 502 на login.

На стенде SQL ALTER COLUMN накачен вручную + запись в
__EFMigrationsHistory; миграция fix только для будущих чистых баз.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:32:52 +05:00
nns 5c5b231157 feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.

Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
  в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
  Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
  IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
  всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
  override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
  если SuperAdmin без override (системная), иначе подставляется
  tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
  OrganizationId DROP NOT NULL на product_groups и units_of_measure.
  Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
  tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
  OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
  если запись OrganizationId=null и юзер не SuperAdmin (только
  суперадмин может править/удалять системные).

Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
  страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
  и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
  реиспользуют существующие компоненты — для SuperAdmin без override
  фильтр возвращает все записи, что в Phase 4+ можно ужесточить
  отдельным эндпоинтом «только системные» (?orgId=null).

Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:20:47 +05:00
nns bd2800837f feat(roles): системные роли read-only + русские имена + чистка дубликата у admin
Концепция: «Супер администратор» — платформенная Identity-роль
SuperAdmin. «Администратор» — организационная роль внутри Employee
(IsSystem=true в EmployeeRole). Они НЕ должны дублироваться у
одного юзера.

- Сидер: admin@food-market.local получает только Identity-роль
  SuperAdmin. Догоняющая ветка для существующих стендов:
  если есть Identity-роль Admin — RemoveFromRoleAsync. На стенде
  AspNetUserRoles почищен SQL'ом.
- AppLayout: translateRoles() переводит SuperAdmin → «Супер
  администратор», скрывает Identity-роль Admin (org-уровень
  показывается через Employee/Role, не через Identity).
- EmployeeRolesPage: клик по строке системной роли → alert
  «Системная роль, изменения недоступны». Edit-модалка для
  системных была частично defensive (disabled чекбоксы Phase 2c),
  теперь точка входа закрыта целиком. Кастомные роли — без изменений.

EmployeeRole.IsSystem поле уже было — миграция не нужна.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:11:14 +05:00
nns 8d72e9da2d feat(super-admin): перенести справочник Стран в системную консоль
Country — глобальный справочник (Entity, не TenantEntity), магазины-
клиенты выбирают страны из готового списка но не управляют ими.
Управление переносится в SuperAdmin консоль.

Изменения:
- API: POST/PUT /api/catalog/countries теперь Authorize(Roles=SuperAdmin)
  (раньше был SuperAdmin,Admin). DELETE и так был SuperAdmin.
- GET остаётся [Authorize] без роли — нужен tenant'у для селектов в
  формах создания орги/контрагентов/товаров.
- Tenant AppLayout: убран блок «Справочники» с пунктом «Страны».
  Иконка Globe больше не импортируется в tenant-меню.
- Tenant роут /catalog/countries удалён из App.tsx.
- В OrganizationSettingsPage ссылка «откройте справочник Страны»
  заменена на текст «обратитесь к администратору платформы».
- SuperAdminLayout: новый блок «Справочники» с пунктом «Страны»
  (/super-admin/countries). Иконка Globe.
- Роут /super-admin/countries использует существующий CountriesPage —
  компонент unchanged, страница теперь рендерится в SuperAdminLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:09:02 +05:00
nns 61cb97a1b7 fix(tenancy): SuperAdmin override должен применять tenant filter выбранной орги
🔴 КРИТИЧНЫЙ БАГ ИЗОЛЯЦИИ. SuperAdmin в режиме «открыть как Demo Market»
видел товары FOOD MARKET (29540 чужих записей вместо 0 своих).

Корень проблемы — query-filter в AppDbContext:

  e => _tenant.IsSuperAdmin || e.OrganizationId == _tenant.OrganizationId

IsSuperAdmin → весь предикат становится true → все записи всех орг.
В режиме override OrganizationId уже корректно подменялся на
выбранную орг, НО bypass через IsSuperAdmin делал подмену
бессмысленной — фильтр всё равно пропускал всё.

Фикс — добавил IsTenantOverride флаг в ITenantContext и переписал:

  e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
       || e.OrganizationId == _tenant.OrganizationId

То есть SuperAdmin обходит фильтр ТОЛЬКО когда не в override. В
override-режиме он работает в контексте выбранной орги как обычный
юзер — фильтр применяется.

HttpContextTenantContext.IsTenantOverride возвращает true когда
текущий запрос — HTTP, юзер в роли SuperAdmin и присутствует header
X-Org-Override с валидным GUID. AsyncLocal-override (background-задачи
импорта/Hangfire) намеренно НЕ считается tenant-override — там
IsSuper=false по умолчанию и фильтр и так применяется.

Smoke-test ДО фикса (воспроизведение):
  GET /products X-Org-Override=DemoId   → total 29540 (баг: чужие)
  GET /products X-Org-Override=FoodId   → total 29540
  GET /products без header              → total 29540 (legit super)

После деплоя ожидается:
  GET /products X-Org-Override=DemoId   → 0 (Demo Market пуст)
  GET /products X-Org-Override=FoodId   → 29540 (своих)
  GET /products без header              → 29540 (legit super bypass)

Затронуты ВСЕ tenant-сущности (фильтр применяется через reflection
ко всем ITenantEntity): products, counterparties, supplies, stocks,
movements, retail-sales и т.д.

DesignTimeTenantContext получил IsTenantOverride=false (он только для
EF tooling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:55:04 +05:00
nns 21c2ca89fe ui(super-admin): SaaS-метрики на главной системной консоли (placeholders)
Food Market — SaaS для розницы, SuperAdmin это владелец платформы,
а не сотрудник магазина. Операционные метрики магазинов («Товаров
29540», «Приёмок за месяц») для него бесполезны — это для tenant
dashboard'а конкретной орги.

KPI блок на /super-admin переработан под кабинет SaaS-провайдера:

Top row (4 карточки):
- Организаций (как было — клиентская база)
- Платящих клиентов — placeholder, accent emerald, muted
- MRR (₸ / мес) — placeholder, accent violet, muted
- Должники — placeholder, accent rose, muted

Second row (2 карточки):
- Пользователей (всего/активных)
- Регистраций за 30 дней — реальное значение
  (COUNT Organizations WHERE CreatedAt >= now-30d)

Заглушки получили проп muted=true: фон чуть серее (slate-50/60),
значение «—» более бледным slate-400, иконка остаётся полноцветной
чтобы было видно «здесь будут данные после Phase 4». В hint —
«Скоро · после внедрения биллинга».

API: DashboardStats потерял TotalProducts/TotalSuppliesThisMonth,
получил RegistrationsLast30Days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:47:13 +05:00
nns 7c161a138b feat(super-admin): рабочий quick-switch + UI-блокировка мутаций в read-only
БАГ: dropdown «Открыть организацию» в topbar системной консоли вёл
сначала на reload текущего /super-admin, а оттуда внутренний редирект
конкурировал с TenantRouteGuard'ом, и юзер видел alert «Откройте
через "Открыть как…"». Фикс — setOrgOverride получил опциональный
{ redirectTo }, делает window.location.assign(target) вместо reload.
Точки вызова обновлены:
- dropdown в SuperAdminLayout topbar → redirectTo='/dashboard'
- кнопка «Открыть как» в строке таблицы орг → redirectTo='/dashboard'
- кнопка «Выйти из режима» в баннере → redirectTo='/super-admin/organizations'

Хук useReadOnly() централизует «override активен И edit-mode не
включён» в один { readOnly, reason }. Button по умолчанию считает
variant='primary' и variant='danger' мутирующими (опт-аут через
mutating={false} для редких primary-без-мутаций) — в read-only они
автоматически disabled с tooltip «Только просмотр. Включите
редактирование в баннере…». Variant='secondary' и 'ghost' не
блокируются — Cancel/Назад/Закрыть остаются кликабельны. Серверный
403 (ReadonlyOverrideMiddleware) остаётся как safety-net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 9d8fd2cb53 ui(super-admin): hero-блок и KPI карточки на главной + три полезных секции
/super-admin переработан:
- Hero на indigo-градиенте: badge «Super Admin Console» + крупный H1
  «Системная консоль» + подзаголовок + env+build справа.
- 4 KPI карточки с цветным круглым фоном иконки (indigo/sky/
  emerald/amber), label uppercase tracking, значение крупно,
  hint мелко серым; hover-shadow.
- Три карточки в ряд (lg:3 cols) вместо дублирующих ссылок-CTA:
  «Здоровье системы» (заглушки /Activity для скорых проверок),
  «Активные организации» (топ-3 по объёму, ссылка «все»),
  «Свежие события» (6 последних audit-записей или empty state
  с Inbox-иконкой и текстом «Журнал пуст. Здесь появятся события»).
- Кнопка «Создать организацию» убрана с главной — оставлена
  только на /super-admin/organizations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 4d4a3a4786 ui(super-admin): читаемый логотип на тёмном sidebar
<Logo> получил пропс variant="dark": в нём «FOOD» рендерится белым
(slate-50) вместо чёрного, «MARKET» — emerald-400 (вместо var brand)
для контраста на indigo-950 фоне SuperAdminLayout. SuperAdminLayout
прокидывает variant="dark" в обоих местах (desktop sidebar + mobile
header). Светлый вариант остался по умолчанию для tenant AppLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 8ae9f68119 feat(super-admin): Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки
SuperAdmin при логине больше не попадает на tenant Dashboard
(Каталог/Товары/Контрагенты/Выручка) — он стоит над всеми органами,
у него отдельная Системная консоль с системным sidebar и системным
дашбордом. Каждый из четырёх кейсов теперь визуально различим:

1. SuperAdmin → /super-admin (индиго-сайдбар, системные разделы:
   Главная / Организации / Пользователи (скоро) / Журнал /
   Здоровье/Бэкапы/Настройки (скоро)). В topbar — компактный
   «Открыть организацию ▼» dropdown для быстрого override.
   Title-suffix « · Super Admin» в browser-tab.
2. Обычный tenant-админ → /dashboard (как раньше).
3. SuperAdmin в режиме «открыть как…» → /dashboard с tenant
   sidebar'ом + amber-баннер сверху + слабая жёлтая тонировка фона
   body чтобы периферийным зрением было ясно «не моя админка».
4. SuperAdmin без override руками вбивает /products → TenantRouteGuard
   редиректит на /super-admin/organizations + alert «Откройте
   конкретную организацию через "Открыть как…"».

Изменения:
- SuperAdminLayout.tsx: индиго-палитра, Logo + badge «Super» + подпись
  «Системная консоль», 7 пунктов меню (5 active + 4 «скоро»),
  user-карточка с «Super Admin», topbar с org-picker dropdown,
  redirect на /dashboard если активен override.
- TenantRouteGuard.tsx: в useEffect проверяет (SuperAdmin && !override)
  и редиректит. Сообщение в sessionStorage для AppLayout, alert один раз.
- App.tsx: setup wizard вне layout'а; /super-admin/* через
  SuperAdminLayout (nested routes); остальное через TenantRouteGuard +
  AppLayout.
- AppLayout: убрана 3-пунктовая группа «Супер-админ» — оставил один
  пункт «Системная консоль» как точку возврата. Тонировка фона
  amber-50/60 при override.
- LoginPage: после успешного login — если me.roles.SuperAdmin и target
  это /, /dashboard → redirect на /super-admin.
- SuperAdminDashboardPage: блок «Последние события» — 10 записей
  audit-log + ссылка на полный журнал.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:13:47 +05:00
nns 6f61bbd974 ui(roles): warning «изменения применятся ко всем сотрудникам» при edit
Edit модалка для кастомной роли (не системной, существующей) теперь
показывает amber-плашку перед матрицей прав — напоминает что галка
тут касается N людей сразу. Системные роли — без плашки (там
disabled чекбоксы).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:08:13 +05:00
nns 4dd6b16eed feat(super-admin): Phase 3 — edit-mode с reason + audit-trail
В режиме «открыть как…» SuperAdmin может временно (на 30 минут)
включить редактирование с обязательной причиной — каждая успешная
мутация пишется в SuperAdminAuditLog. Чтобы лог был полезным:

API:
- Header X-Org-Override-Reason. Если присутствует и trimmed >= 10
  символов — ReadonlyOverrideMiddleware пропускает мутации (вместо 403).
- SuperAdminEditAuditFilter — глобальный IAsyncActionFilter после
  controller'а: при наличии обоих headers + успешном статусе 2xx +
  методе POST/PUT/PATCH/DELETE пишет запись ActionType=EditEntity
  с reason, описанием «METHOD /path → 200», IP и SuperAdminUserId.
  Регистрируется как Scoped + AddService<>() в AddControllers.

Web:
- enableEditMode(reason)/disableEditMode/getEditMode в lib/api.ts —
  хранение в localStorage с expiresAt = now + 30мин. Axios interceptor
  добавляет header только пока edit активен и не истёк.
- SuperAdminAsOrgBanner расширен: цвет меняется amber→red в edit-mode,
  кнопка «Включить редактирование» открывает модалку с textarea
  reason (≥10 символов) + чекбокс согласия на аудит. После активации
  баннер показывает «EDIT-MODE (N мин)», кнопка «Снять edit» отключает
  до истечения таймера.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:07:10 +05:00
nns b2d6584f09 feat(super-admin): Phase 2 — read-only «открыть как…» context switch
SuperAdmin может зайти в данные конкретной орги в режиме просмотра
(аналог «view as customer» в SaaS). Все запросы летят с tenant'ом
выбранной орги, но любая мутация запрещена — Phase 3 (edit-mode +
audit-trail) будет ослаблять ограничение по reason'у.

API:
- HttpContextTenantContext.OrganizationId: если у юзера роль SuperAdmin
  И header X-Org-Override присутствует — возвращаем его как tenant
  (вместо org_id из JWT).
- ReadonlyOverrideMiddleware (после UseAuthorization): когда заголовок
  активен, отбивает 403 любую non-GET операцию, кроме /api/super-admin/*
  (управление орг) и /connect/* (refresh tokens).
- Безопасность: проверка SuperAdmin-роли — без неё header игнорируется,
  обычный юзер ничего подменить не может.

Web:
- api.ts: localStorage 'superAdminAsOrg' = {id, name}; axios interceptor
  добавляет X-Org-Override на каждый запрос. setOrgOverride(...) делает
  hard reload чтобы сбросить TanStack Query-кэш.
- SuperAdminAsOrgBanner — жёлтая полоса сверху main-area с названием
  орги и кнопкой «Выйти». Подключена в AppLayout перед <Outlet/>.
- В таблице /super-admin/organizations добавлена кнопка LogIn (синяя)
  в actions; клик → setOrgOverride → reload в режим просмотра.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:03:48 +05:00
nns 8ff0a56144 feat(bridge): /quiet и /loud команды для управления PreToolUse прогресс-лентой
- /quiet → создаёт /tmp/cc-tg-quiet, pretool-hook сразу выходит,
  Stop hook продолжает работать (финальные ответы летят).
- /loud  → удаляет flag, прогресс-лента возобновляется.
- /ping без изменений.

На стенде bridge перезапущен, setWebhook 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:59:05 +05:00
nns 59983acabd fix(super-admin): новая org через UI получает полный bootstrap (как Demo)
POST /api/super-admin/organizations создавал только Store + Admin role
в inline-коде — у новой орги не было единиц измерения, типов цен,
кастомных ролей шаблонов (Менеджер/Кладовщик/Закупщик/Бухгалтер),
кассы. Юзеру приходилось бы заводить всё руками.

Решение — переиспользовать DevDataSeeder.SeedTenantReferencesAsync,
который уже умеет всё это идемпотентно:
- 5 единиц измерения (штука/кг/л/м/упаковка по ОКЕИ)
- 2 типа цен (Розничная IsSystem+IsRequired+IsRetail / Оптовая)
- «Основной склад» MAIN
- «Касса 1» POS-1
- 6 ролей через SeedEmployeeRolesAsync (2 системных + 4 шаблона)

Helper повышен с private на public static. В контроллере убран
inline Store + AdminRole, после Add(org)+SaveChanges вызывается
seed, потом находим уже созданную «Администратор» роль для линковки
с Employee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:58:23 +05:00
nns 92d2eb0432 feat(infra): PreToolUse hook for Telegram progress feed + rate-limited batching
Hook /usr/local/bin/cc-tg-notify-pretool читает JSON через stdin
(tool_name + tool_input) и шлёт короткую строку прогресса в Telegram
перед каждым tool-вызовом — чтобы юзер видел «он работает» а не
просто ждал финального ответа от Stop hook.

Форматы (по требованию ТЗ):
- Bash       → 🔨 ${description} либо 🔨 Bash: ${command[:80]}
- Edit/Write/Read → ✏️/📝/📖 + basename(file_path)
- Grep/Glob  → 🔍/🌐 + pattern[:30/50]
- WebFetch/Search → 🌍/🔎 + url|query[:60]
- Task       → 🎯 + description[:60]
- TodoWrite  → skip (шумно)
- прочие     → 🔧 ${tool_name}

Дебаунс через flock + фоновый sleep:
- Каждый вызов append'ит строку в /tmp/cc-tg-pretool-buffer.txt и
  бампит /tmp/cc-tg-pretool-last (epoch ms) под flock'ом.
- Спавнит фоновый sleep 1.5s; после пробуждения проверяет,
  чей timestamp в LAST — если не его, выходит молча. Только
  «последний» hook реально шлёт батч одним сообщением.
- Это даёт пачке tool-вызовов один Telegram-апдейт, а не 5–10.
- Если буфер длиннее 20 строк — режется хвостом (свежие важнее).

Off-switch: touch /tmp/cc-tg-quiet — pretool-hook сразу выходит.
Stop hook продолжает работать.

Hook регистрируется в ~/.claude/settings.json под PreToolUse с
matcher="" (все tools без фильтра); фильтр TodoWrite — внутри
скрипта.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:12:11 +05:00
nns a7fcc9f9e1 fix(seed): grant SuperAdmin role to admin@food-market.local
Раздел /super-admin в UI прячется за me.roles.includes('SuperAdmin').
Сидер при создании admin'а назначал только SystemRoles.Admin —
SuperAdmin висел как Identity-роль в роле-каталоге, но никому не был
выдан. Из-за этого SuperAdmin-консоль на стенде была не видна в меню.

Фикс: при создании admin'а сразу AddToRoleAsync(SuperAdmin). Для уже
развёрнутых стендов — догоняющая ветка else if (!IsInRoleAsync(SuperAdmin))
догоняет существующую учётку при следующем рестарте API.

На стенде роль уже выдана вручную через INSERT в AspNetUserRoles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:06:42 +05:00
nns e3bc5cacfc feat(web): super-admin section + setup wizard + auto-redirect
Раздел /super-admin/* доступный только пользователю с ролью SuperAdmin.
В сайдбаре отдельная группа «Супер-админ» появляется только если
me.roles содержит SuperAdmin (UI-guard, серверная авторизация
параллельно проверяется через [Authorize(Roles=\"SuperAdmin\")]).

Страницы:
- /super-admin            — KPI-дашборд (orgs/users/products/supplies)
- /super-admin/organizations — таблица всех org с фильтром Активные/
  Архив/Все, поиском по Name/BIN. Действия в строке: Архивировать
  (модалка с вводом названия), Восстановить, Удалить навсегда (только
  если в архиве >30 дней + повторное подтверждение).
- /super-admin/organizations/new — двух-секционная форма создания
  (реквизиты + первый админ); в response получаем generatedPassword
  и показываем в одноразовой модалке с copy-кнопками.
- /super-admin/audit-log  — таблица журнала с экспортом в CSV.
- /super-admin/setup      — 3-шаговый wizard (welcome → org → admin →
  done с временным паролем). Авто-редирект на этот URL для SuperAdmin
  если /api/super-admin/setup-status возвращает needsSetup=true (в
  системе ноль организаций) — нельзя пропустить пока не создадут.

useMe()/useIsSuperAdmin() хуки в lib/useMe.ts — единая точка чтения
текущего юзера и его ролей для UI-гейтов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:59:07 +05:00
nns 18eb362702 feat(api): super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard)
SuperAdminOrganizationsController (/api/super-admin/organizations):
все методы используют IgnoreQueryFilters() для обхода tenant-фильтра.
- GET / — таблица с пагинацией, фильтр archived, поиск по Name/Bin,
  возвращает счётчики (employees, products) + last login по users.
- GET /{id} — детали + статистика (employees, products, counterparties,
  supplies за 30 дней) + AccountOwner данные.
- POST / — создание орга вместе с админом: Org + Store «Основной» +
  EmployeeRole «Администратор» (IsSystem) + AppUser (random temp pwd
  возвращается один раз) + Employee. Owner = созданный AppUser.
- PUT /{id} — правка базовых данных, лог EditOrg с before/after.
- POST /{id}/archive — требует ConfirmationName == Org.Name (ввод).
- POST /{id}/restore — снять архив.
- DELETE /{id} — только если в архиве >30 дней + повторное подтверждение.
- POST /{id}/change-owner — Reason обязателен, валидируем что user
  принадлежит этой орге, лог ChangeOwner с from/to.

Все мутации пишут запись в SuperAdminAuditLog с ActionType,
Description, Reason, ChangesJson, IpAddress, SuperAdminUserId.

SuperAdminController (/api/super-admin):
- GET /setup-status — нужен ли wizard? (OrgCount == 0).
- GET /dashboard — total/active/archived orgs, users, products, supplies/month.
- GET /audit-log — фильтры organizationId/actionType/from/to + paged + join
  на orgs для имени.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:54:07 +05:00
nns e93634fad4 feat(domain): Organization.IsArchived/AccountOwner + SuperAdminAuditLog + migration
Базовый domain-каркас для SuperAdmin console (Phase 1):

Organization:
- IsArchived bool + ArchivedAt DateTime? — архивная орга не видна
  юзерам, но данные сохраняются. Удалить навсегда можно только из
  архива >30 дней (логика в API на следующем коммите).
- AccountOwnerUserId Guid? — главный владелец, не путать с админами
  per-org. SuperAdmin может сменить через action c reason в audit-log.
- HasIndex(IsArchived) для быстрой фильтрации.

SuperAdminAuditLog (новая таблица super_admin_audit_log):
- Не tenant-scoped — лог общий по всей системе.
- ActionType (CreateOrg/EditOrg/ArchiveOrg/RestoreOrg/DeleteOrg/
  ChangeOwner/EditEntity), OrganizationId, EntityType+EntityId,
  Description, Reason, ChangesJson (jsonb), IpAddress.
- Индексы: CreatedAt, (SuperAdminUserId, CreatedAt),
  (OrganizationId, CreatedAt) — типовые запросы фильтра.

Migration Phase4_SuperAdminConsole добавляет 3 колонки в organizations
+ создаёт super_admin_audit_log с тремя композитными индексами.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:51:25 +05:00
nns bf6880fe0f feat(employee): add Salary, TaxNumber, Description, ImageUrl + radio role picker
Domain Employee расширен 4 nullable-полями (по образу сторонняя система):
- Salary numeric(18,2) — оклад в валюте организации
- TaxNumber varchar(20) — ИИН/ИНН
- Description varchar(2000) — комментарий HR'а
- ImageUrl varchar(500) — аватар (на будущее: загрузка через images endpoint
  как у товаров; пока поле для прямой ссылки)

Migration Phase4c_EmployeeExtraFields добавляет 4 nullable колонки
(существующие записи не ломаются). EF config + snapshot обновлены.

API EmployeesController: DTO/Input/Create/Update пробрасывают новые
поля сквозь.

Frontend EmployeesPage:
- Поля «Оклад» и «ИИН/ИНН» рядом, ниже — «Описание» textarea.
- Селект роли заменён на radio-список с описанием каждой роли
  (системные сначала, затем кастомные). Под радио — ссылка
  «Настроить права ролей →» на /settings/employee-roles. Это
  по образу МС — пользователь сразу видит за что отвечает каждая
  роль и куда идти если нужно подкрутить.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:48:27 +05:00
nns 372ca9ec16 feat(roles): permissions matrix grouped by section + clone-from-template flow
RolePermissions расширен с 21 до 32 флагов: добавлены UnitsManage,
DemandsView/Edit/Post (отгрузка контрагенту, не путать с RetailSales),
CounterpartiesDelete, InventoryEdit, LossEdit, EnterEdit (склад-операции),
ReportsFinanceView, ReportsStockView (тонкие отчётные права),
CashRegistersManage и IntegrationsManage (отдельно от OrgSettingsManage).

UI EmployeeRolesPage:
- 7 групп вместо 6: Каталог / Закупки / Продажи / Контрагенты /
  Склад-Остатки / Отчёты / Настройки. Все секции аккордеоном внутри
  модалки (как было — flex-col), но с правильным грануляр-списком.
- Системные роли — чекбоксы disabled (только просмотр; имя/описание
  редактируются).
- При [+ Добавить роль] — сначала открывается модалка выбора шаблона:
  Пустой / Копия Администратора / Копия любой существующей. Дальше
  открывается основная модалка с предзаполненной матрицей.

allPerms() помощник на фронте — зеркало RolePermissions.All() с бэка,
для шаблона «Копия Администратора» в clone-flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:44:53 +05:00
nns 1d9fd7297c fix(roles): keep only Admin + Cashier as system, demote others to custom + migration
После ревью UX оказалось что 6 системных ролей — перебор. Перешли на
схему «два системных + остальные шаблоны»:

- Администратор (IsSystem=true) — RolePermissions.All().
- Кассир (IsSystem=true) — POS-only набор:
  ProductsView + StocksView + RetailSalesOperate. Без RetailSalesRefund
  (админ включит при необходимости). Это маркер для будущего POS-app —
  не имеет доступа к веб-админке.
- Менеджер / Кладовщик / Закупщик / Бухгалтер — IsSystem=false
  (кастомные). Можно удалить если не нужны или подкрутить под себя.

Сидер на чистой БД сразу создаёт роли в правильных статусах. Для
существующих установок миграция Phase4b_RolesSimplify идемпотентно
делает UPDATE: демоутит лишние и приводит permissions Кассира к
правильному набору. Down() — no-op (юзер мог переименовать).

На стенде sql применил вручную + записал в __EFMigrationsHistory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:41:13 +05:00
nns d2305b7d40 feat(infra): event-driven Telegram bridge — webhook + Stop hook
Полный отказ от 2-секундного polling tmux'а в пользу реактивной схемы:

OUTBOUND (server-Claude → Telegram) через Stop hook:
- /usr/local/bin/cc-tg-notify-stop (Bash) читает transcript из stdin
  (Claude Code передаёт {transcript_path}), достаёт последнюю
  assistant-запись с непустым text-блоком (jq), чанкует ≤4000 символов
  с префиксом «🤖 [food-market]», POST'ит в Telegram через curl.
  Логи /var/log/cc-tg-notify.log. Если turn без текстового ответа
  (только tool calls) — выходит молча.
- Зарегистрирован в ~/.claude/settings.json под Stop event с пустым
  matcher (все turns).

INBOUND (Telegram → bridge → tmux) через webhook:
- bridge.py переписан с run_polling на run_webhook listening
  127.0.0.1:8765 на /tg-webhook. python-telegram-bot[webhooks]
  (tornado) ставится через pip.
- При старте сам делает setWebhook к Telegram API с secret_token
  из TELEGRAM_WEBHOOK_SECRET (osprandom 24 hex), Telegram присылает
  его обратно в X-Telegram-Bot-Api-Secret-Token — PTB валидирует
  до вызова handler'ов.
- Сохранены: whitelist по chat_id, paste-в-tmux через
  send-keys -l + Enter, /ping команда. Удалён poll_and_forward,
  diff/clean логика, recently_sent_lines дедуп — больше не нужны.

Nginx: новый location = /tg-webhook на food-market-stage.conf,
проксирует на 127.0.0.1:8765 с прокидыванием X-Telegram-Bot-Api-
Secret-Token. Smoke-test: curl с неверным секретом → 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:38:44 +05:00
nns 5737c65215 fix(migrations): drop Employee.Navigation(RetailPointAssignments) to fix snapshot order
В snapshot/Designer я вручную добавил b.Navigation(\"RetailPointAssignments\")
в блоке Employee — но эта обратная навигация регистрируется через
WithMany(\"RetailPointAssignments\") у EmployeeRetailPointAssignment.HasOne(...),
который выполняется ПОЗЖЕ. Из-за этого BuildTargetModel падал с
«Navigation … was not found», и API на стенде не мог применить миграцию.

Убрал лишнюю строку в обоих местах. Свойство Employee.RetailPointAssignments
никуда не делось — обратную навигацию EF создаёт автоматически из конфига
EmployeeRetailPointAssignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:11:13 +05:00
nns d3bcbee8b9 feat(onboarding): welcome dashboard with first-steps cards
Дефолтная страница после логина (/) — OnboardingPage по образу
сторонняя система «Первые шаги». Старый DashboardPage с KPI и графиком
переехал на /dashboard, в меню «Главная» теперь два пункта:
«Главная» (онбординг) и «Аналитика» (KPI/графики).

useOnboardingProgress() — хук, считает 4 шага:
- orgConfigured: country + defaultCurrency установлены
- hasEmployees: > 1 сотрудник (помимо админа)
- hasProducts: > 0 товаров
- hasSupplies: > 0 приёмок

OnboardingPage:
- Прогресс-бар «N из 4 шагов» с процентом
- 4 карточки задач: Настройки → Сотрудники → Каталог/Импорт → Приёмка
- Каждая показывает иконку (CheckCircle2 если done) + бэйдж
  категории + заголовок + описание + CTA-кнопка с ArrowRight,
  меняющая текст и ссылку в зависимости от done.
- Когда все 4 шага сделаны — плашка «🎉 Готово!» + переход на
  /dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:08:03 +05:00
nns f5cccb6f10 feat(web): Employees + Roles pages with permissions matrix
EmployeesPage (/settings/employees):
- Таблица: ФИО + должность, Роль, Email, Телефон, Учётка (есть/нет),
  Статус (Активен/Уволен).
- Модалка добавления: ФИО + Position + Email + Phone + Role.
  Если выбрана роль «Кассир» — появляется блок «Кассы» с чекбоксами
  привязки к RetailPoint'ам (multi-select).
- Чекбокс «Создать учётную запись» (по умолчанию ✓): сервер
  возвращает generatedPassword один раз, показываем в отдельной
  модалке с copy-кнопками логина и временного пароля.
- Update/Delete как обычно. Снять Активен → серверная установка FiredAt.

EmployeeRolesPage (/settings/employee-roles):
- Таблица системных + кастомных ролей с счётчиком активных прав
  (N/21). Системные помечены бэйджем «Системная».
- Модалка edit: имя, описание, матрица прав сгруппированная по 6
  блокам (Каталог/Закупки/Продажи/Контрагенты/Отчёты/Настройки).
  Удаление кнопка только для кастомных.

Меню «Настройки организации» дополнено пунктами «Сотрудники»
(иконка UserCog) и «Роли» (иконка Shield).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:06:03 +05:00
nns c714ec265c feat(api): EmployeesController + EmployeeRolesController + invite-with-temp-password
EmployeeRolesController (/api/organization/employee-roles):
- List/Get/Create/Update/Delete. Системные роли (IsSystem=true) — нельзя
  удалить (409), но имя/описание/permissions редактируются (чтобы можно
  было кастомизировать набор галок). Удаление 409 если роль уже
  используется сотрудниками.

EmployeesController (/api/organization/employees):
- List с поиском по фамилии/имени/email/телефону.
- Create:
  - LastName, FirstName, MiddleName, Position, Email, Phone, RoleId, IsActive
  - RetailPointIds[] — для роли Кассир привязка к нескольким кассам;
    хранится в employee_retail_point_assignments.
  - CreateAccount=true → одновременно создаём User (Identity) с email и
    случайным temp-паролем (12 символов, все классы), возвращаем в
    response.GeneratedPassword один раз — UI покажет «выдайте сотруднику».
- Update — replace assignments wholesale; IsActive false → проставляем
  FiredAt=now (восстановление обнуляет).
- Delete — без проверок на FK документов (на этом этапе нет других
  ссылок на Employee, кроме CASCADE-связи с retail-point assignments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:03:39 +05:00
nns 0ff31d1450 feat(seed): system roles per organization + map admin → Employee
DevDataSeeder.SeedEmployeeRolesAsync — 6 системных ролей с готовыми
наборами Permissions:
- Администратор    — RolePermissions.All() (все 21 флаг)
- Менеджер         — каталог + закупки + контрагенты + отчёты + остатки
- Кладовщик        — приёмки + остатки + view товаров
- Кассир           — продажи + view товаров (привязка к кассе на UI-этапе)
- Закупщик         — закупки + контрагенты + view товаров
- Бухгалтер        — все *View, никаких edit

IsSystem=true, SortOrder сохраняет порядок отображения в селектах.
Сидируется один раз per организацию (anyRole? skip) — чтобы кастомные
правки галок админа не сбрасывались на каждый старт.

SeedAdminEmployeeAsync — после создания admin@food-market.local
(SuperAdmin Identity user) заводит Employee-запись с ролью
«Администратор» в Demo Market организации, чтобы UI «Сотрудники»
сразу показывал учётку, а не пустой список.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:01:44 +05:00
nns f38d34f42d feat(domain): Employee, EmployeeRole, RolePermissions entities + migration
Базовый каркас модуля «Сотрудники и Роли» (по образу сторонняя система):

Domain:
- Employee — сотрудник организации (UserId nullable: запись может
  существовать без логина), ФИО + Position + Email/Phone + Role + IsActive
  + FiredAt + RetailPointAssignments.
- EmployeeRole — роль с IsSystem флагом и owned RolePermissions.
- RolePermissions — 21 булев флаг по группам (Каталог/Закупки/Продажи/
  Контрагенты/Отчёты/Настройки) + helper All() для админа.
- EmployeeRetailPointAssignment — ассоциация сотрудника с RetailPoint
  (для роли Кассир — к каким кассам привязан).

Infrastructure:
- OrganizationsHrConfigurations с OwnsOne(...).ToJson("permissions")
  для permissions — JSONB-колонка вместо отдельной таблицы.
- DbSet<EmployeeRole/Employee/EmployeeRetailPointAssignment>.
- Уникальные индексы: (OrgId, RoleName), (OrgId, UserId) с filter
  WHERE UserId IS NOT NULL, (EmployeeId, RetailPointId).

Migration Phase4_EmployeesAndRoles создаёт три таблицы. Сидер
системных ролей и привязка существующего admin'а к Employee —
следующим коммитом, контроллеры и UI — далее.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:00:30 +05:00
nns cec76ecaaf refactor(retail-points): rename «Точка продаж» → «Касса» + перенос
складов и касс в раздел «Настройки организации»; useOrgInfra хук

UI-переименование:
- RetailPointsPage: title «Кассы», description обновлён, лейблы
  «Новая касса» / «Удалить кассу?»; доменная сущность RetailPoint
  и URL /api/catalog/retail-points сохранены — DTO/БД не трогаем.
- В сайдбаре пункты «Склады» и «Кассы» перенесены из бывшей
  группы «Склады» в группу «Настройки организации» (рядом с
  «Общие»). Старые пункты верхнего уровня убраны.

useOrgInfra() — общий хук:
- возвращает stores, cashRegisters, defaultStoreId, defaultCashId
- showStorePicker / showCashPicker = length > 1 (умное скрытие
  селекторов в формах документов когда инфра одна).
В SupplyEditPage скрытие склада уже работало через
(stores.data?.length ?? 0) > 1 — оставил как есть, новый хук
для будущих документов (продажи, инвентаризации).

Сидер default Store + RetailPoint per Organization уже есть в
DevDataSeeder.cs (Основной склад MAIN + Касса 1 POS-1) —
дополнять не нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:53:49 +05:00
nns dc4b5360b9 fix(searchable-select): dropdown opens as floating overlay (Portal + absolute)
Старый dropdown использовал position:absolute внутри Section (а у того
overflow-hidden для скруглений), из-за чего он клипался границами
карточки. На некоторых страницах визуально это смотрелось так, будто
список «раздвигает» layout.

Решение — тот же Portal-паттерн что у SupplyLineQuickAdd и DateField:
- dropdown рендерится через createPortal в document.body
- position: fixed, координаты по getBoundingClientRect() trigger'а
- z-[100], sticky-headers/секции не перекроют
- Auto-flip: если внизу <240px и сверху больше — открываем вверх
  (anchor через bottom: window.innerHeight - rect.top)
- Outside-click учитывает обе ноды (wrap + dropdown в portal)
- На window.scroll/resize dropdown закрывается чтобы не «уплывать»
  относительно trigger'а

Фиксит сразу все Select'ы в проекте (тип штрихкода, валюта, страна,
группа товара, тип цены и т.д.) — компонент один.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:56:03 +05:00
nns e96f1cdc86 fix(date-field): theme styles + default to today for new docs
Тема react-datepicker подогнана под Tailwind/slate-стиль остального UI:
- font-family inherit, шрифт системный (не Helvetica дефолт);
- header bg-slate-50, border slate-200, скругления 0.5rem;
- выбранный день — заливка var(--color-brand) (фирменный зелёный);
- сегодня — bold + brand color;
- день под клавиатурой — slate-100 без заливки brand;
- стрелки навигации серые slate-500 (без bootstrap-синего);
- footer «Сегодня» — приглушённый slate-50 с brand-цветом текста;
- треугольник-указатель скрыт;
- крестик clear — slate-400 с hover slate-500;
- весь dark-вариант через prefers-color-scheme — slate-800/900.

todayIso() в SupplyEditPage переписан с toISOString() (UTC) на
локальный YYYY-MM-DD — иначе в часовом поясе KZ (UTC+5) с 03:00
до 05:00 утра новая приёмка получала бы вчерашнюю дату.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:51:19 +05:00
nns 39da12edec feat(date-field): replace native input with react-datepicker — polished UX
Нативный <input type=\"date\"> рендерил американский MM/DD/YYYY и
тонкий браузерный popup, выглядел криво рядом с другими полями.

Используем готовый react-datepicker (10M downloads/week) — никакой
кастомизации, всё из коробки:
- dateFormat=\"dd.MM.yyyy\" + locale=\"ru\" → «25.04.2026», русские
  Январь/Понедельник
- showMonthDropdown + showYearDropdown + dropdownMode=\"select\" →
  быстрый прыжок на любой месяц/год
- todayButton=\"Сегодня\" → кнопка под календарём
- isClearable → крестик в input для очистки
- popperClassName z-[100] чтобы попап не резался z-стеком

ISO YYYY-MM-DD внутрь/наружу собираем вручную из локальных Y/M/D —
не toISOString(), чтобы вечерние даты в часовом поясе KZ (UTC+5)
не сдвигались на день назад.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:39:51 +05:00
nns dc162a6c06 revert(date-field): drop custom react-day-picker — use native input type=date
Кастомный DateField на react-day-picker оказался хрупким (стрелки
навигации не реагировали, dropdown лет вылазил за popover). В проекте
нет shadcn/ui-обёртки над day-picker, а пилить её с нуля под одно
поле — overkill.

Откатил на нативный <input type=\"date\"> с max-w-[180px], чтобы
поле не растягивалось на всю колонку. Браузер сам подтягивает
локаль из ОС/настроек — у пользователя с RU-локалью календарь
будет на русском, формат DD.MM.YYYY (как в его референс-скриншоте).

- Удалён components/DateField.tsx.
- В SupplyEditPage возвращён <TextInput type=\"date\"> с
  className=\"max-w-[180px]\".
- Сняты зависимости react-day-picker и date-fns.

Если когда-нибудь вернёмся к кастомному picker'у — будем ставить
shadcn/ui calendar+popover целиком, не вручную.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:32:41 +05:00
nns c54c26cf2b fix(date-field): polish calendar UX — dropdown nav, today/clear footer, ru weekdays
Календарь приведён к виду нативного date-picker macOS:

- captionLayout="dropdown" + startMonth/endMonth — в шапке
  селекты «Апрель» и «2026» (можно прыгнуть на любой месяц/год
  без 12 кликов next).
- Стрелки навигации справа в шапке (absolute right-1 top-1),
  компактные 28×28, hover-bg, без ярко-синего акцента.
- footer prop — две inline-ссылки «Очистить» (сбрасывает) и
  «Сегодня» (ставит сегодняшнюю дату); border-t над ними.
- Сегодня = синяя заливка bg-brand text-white (как на референсе);
  выбранная дата = ring-2 ring-brand; если сегодня = выбранный —
  применяются обе (сначала заливка, потом ring).
- Ширина popover'а w-[340px], ячейки 36×36, weekday и dropdown
  capitalize → «Пн Вт Ср …», «Апрель 2026».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:22:48 +05:00
nns 6a673e536e fix(date-field): compact calendar popup — shadcn-style sizing
Календарь react-day-picker раздувался на ~700px из-за дефолтных
стилей. Теперь plотный shadcn-style ~280–290px:

- CSS-переменные v9 на враппере: --rdp-day-height/width=2rem,
  --rdp-day_button-height/width=2rem, --rdp-weekday-padding=0,
  --rdp-accent-color=brand. Контейнер p-3 text-sm.
- classNames переписаны: ячейка дня h-8 w-8 (32×32) с rounded-md,
  weekday w-8 capitalize, caption_label text-sm font-medium.
  Кнопки навигации 24×24 с hover-bg, chevron 16×16.
- Сегодня: bg-slate-100 + font-semibold (без жирной обводки).
  Выбранная дата: bg-brand text-white. Outside-дни выцветшие.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:19:13 +05:00
nns c31611d6c4 fix(date-fields): cap width + ru locale + DD.MM.YYYY format
Новый компонент <DateField/>:
- Ширина зафиксирована (по умолчанию w-40 = 160px) — раньше нативный
  <input type="date"> растягивался на всю колонку, хотя содержимое
  всегда 10 символов.
- Ввод в формате DD.MM.YYYY с авто-вставкой точек после dd и mm,
  inputMode=numeric для мобилы. Хранит/отдаёт ISO YYYY-MM-DD —
  API-контракт не меняется.
- Иконка календаря справа открывает попап (через portal в body,
  position fixed) с react-day-picker: locale=ru, weekStartsOn=1,
  ISOWeek; caption_label/weekday с capitalize CSS — «Апр 2026»,
  «Пн Вт Ср …». Outside-click закрывает.

Подключено в SupplyEditPage (поле «Дата»). Ставка на единый
компонент DateField — все будущие даты в системе через него.

Зависимости: + react-day-picker ^9, + date-fns ^4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:13:35 +05:00
nns 7d09873be2 fix(supply-lines): show both article and barcode in line subtitle
В таблице позиций приёмки под названием товара теперь выводится
и артикул, и основной штрихкод сразу — раньше показывалось что-то
одно (артикул или ничего, без штрихкода).

Формат: «Арт: 17933 · ШК: 4870144022958». Если только одно из двух
— префикс соответствующий, без точки-разделителя. Если ни того ни
другого — subtitle не рендерится. Шрифт мелкий моно серый.

API:
- SupplyLineDto расширен полем ProductBarcode (основной по
  IsPrimary, иначе первый по порядку).
- В проекции GetInternal штрихкод подтягивается через
  p.Barcodes.OrderByDescending(IsPrimary).Select(Code).First().

Frontend:
- types.ts.SupplyLineDto, LineRow в SupplyEditPage и AddedProduct
  в SupplyLineQuickAdd получили поле productBarcode/barcode.
- При добавлении строки через ProductPicker, sticky-input или
  quick-create — primary barcode достаётся из p.barcodes одинаковой
  логикой (sort by IsPrimary desc, [0]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:03:43 +05:00
nns 970a9baec3 fix(supply-quick-add): sticky input at viewport bottom + auto-scroll on add
Quick-add bar теперь не sticky-внутри-Section, а отдельный flex-sibling
формы — всегда прибит к нижнему краю viewport независимо от высоты
содержимого и overflow-hidden у Section'а:

  <form flex flex-col h-full>
    <topbar />
    <body flex-1 overflow-auto>     ← скроллится
    <quick-add bar flex-shrink-0>   ← всегда виден
  </form>

После каждого добавления строки скролл-контейнер тела документа
автоскроллится к низу (smooth, через requestAnimationFrame чтобы
дождаться рендера новой строки) — новая строка всегда появляется
прямо над input'ом и пользователь видит подтверждение скана.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:42:24 +05:00
nns b56c499b45 fix(supply-quick-add): dropdown opens upward + show only N results + create-new at bottom
UX как в сторонняя системае:
- Dropdown открывается ВВЕРХ от input'а (anchor по input.top, fixed
  bottom). Max-height 60vh — не перекрывает шапку, внутри overflow-y.
  Раньше выпадал вниз и при добавлении строк визуально не оставалось
  места.
- Показываем первые 10 матчей (VISIBLE_LIMIT=10), под ними ссылка
  «Ещё N товаров» которая раскрывает полный список (limit=50 на
  сервере). На запрос приходит до 50 — этого достаточно для
  подавляющего большинства поисков.
- Под списком (через тонкий разделитель) — пункт «+ Создать новый
  товар: «{q}»» / «Создать товар со штрихкодом «…»». Всегда последний
  визуально, ближайший к input'у — самый удобный для быстрого клика.

Стрелки ↑↓ работают по видимой части (1..10 или весь список после
expand). Enter подбирает подсвеченный из видимой части.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:33:57 +05:00
nns 95bf2188c6 feat(product-card+list): drop supplier field, reorder sections, add cost column
Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/DTO
  оставлены без изменений; в payload отправляется null);
- порядок секций: Основное → Цены → Классификация → Изображения →
  Штрихкоды (раньше Цены шли после Классификации). Цены — самое важное,
  должны быть ближе к названию товара.

Список товаров:
- добавлена колонка «Себестоимость» перед колонкой системной розничной
  цены. Источник — Product.Cost (скользящее среднее, обновляется при
  проведении приёмки). Cost = 0 (приёмок не было) показывается как «—»,
  чтобы визуально отличать «не накопилось» от реальной себестоимости 0.
- API: добавлен сортировочный case sort=cost,asc/desc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:13:57 +05:00
nns 94d3c4687b fix(supply-quick-add): keep input focused after scan / clear on add
Сценарий — приёмщик подряд сканирует 50 штрихкодов без клика мышью:

- Sticky-bar с input'ом теперь ВНЕ Section'а (overflow-hidden родителя
  ломал sticky), прибит к низу скроллируемого тела документа. После
  любого добавления строки — input всегда виден.
- Очистка query и refocus вызываются СРАЗУ после клика/Enter, до
  await на /products/{id}: пока сеть в полёте, юзер уже может начать
  следующий скан. После завершения запроса — повторный refocus
  (двойной guarded focus + requestAnimationFrame), чтобы перебить
  любые ререндеры родителя, которые могут увести фокус.
- Поиск по quick-search теперь через AbortController — устаревший
  ответ при быстром вводе подряд не подменяет свежий список.
- Закрытие ProductQuickCreateModal тоже возвращает фокус в input
  (и при «Создать», и при «Отмена»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:08:49 +05:00
nns f7deecc41c fix(supply-quick-add): dropdown not rendering — Portal + fixed position
Корень проблемы: Section рендерится с класcом overflow-hidden (нужен
для скруглений углов) — absolute-позиционированный dropdown
обрезался границей карточки и был не виден совсем.

Решение: dropdown через React createPortal вынесен в document.body,
позиция вычисляется по getBoundingClientRect() input'а на каждый
open + window scroll/resize. position: fixed, z-[100] — выше любого
sticky-header. Outside-click handler теперь учитывает оба контейнера
(input wrap + portal-узел) — клик по элементу dropdown'а больше
не закрывает его как «снаружи».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:01:49 +05:00
nns 9008939249 chore(web): remove standalone Currencies page (managed via Country FK)
Валюты — закрытый сидируемый список (KZT, USD, EUR, RUB), пользователь
их не создаёт. Currency остаётся в БД как FK для Country и Organization;
GET /api/catalog/currencies продолжает работать для дропдаунов в форме
страны и настройках организации. Отдельная страница в меню избыточна.

- удалена pages/CurrenciesPage.tsx
- из роутера убран /catalog/currencies
- из сайдбара пропал пункт «Валюты», иконка Coins больше не импортируется

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:01:49 +05:00
nns 532c1aaf24 feat(supply): inline line quick-add — scanner + autocomplete + create-on-fly
UX как в сторонняя система: под таблицей строк единый input full-width с
автофокусом, на каждый ввод — debounce 200ms и quick-search в API.
Dropdown показывает артикул + название (с подсветкой матча) +
бэйдж текущего остатка по складу документа (зелёный/красный/серый).

Сценарии:
- Сканер (цифры 8/12/13/14 + Enter) → точный barcode lookup,
  единственный матч добавляется мгновенно. Несколько → пользователь
  выбирает из dropdown.
- Текст + Enter → берёт подсвеченный пункт автокомплита.
- Дубль того же товара → Quantity += 1 у существующей строки,
  всплывает «Кол-во увеличено на 1».
- Ничего не нашлось → пункт «Создать новый товар: «{q}»» в дропдауне
  и при Enter, открывает ProductQuickCreateModal с pre-fill в зависимости
  от типа запроса (barcode/article/name).
- ↑↓ навигация, Esc/Tab закрывают, после добавления input очищается
  и возвращает фокус — для сканирования партии подряд.

Кнопка «+ Добавить из справочника» (правый верх секции) — без изменений,
открывает ProductPicker с фильтрами и multi-select для bulk-добавления.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:55:57 +05:00
nns 48babf0d10 feat(api): products quick-search + by-barcode endpoints
GET /api/catalog/products/quick-search?search=&storeId=&limit=20 —
лёгкий поиск для inline-добавления строк в документы. Ранжирует
по приоритету: точный barcode → точный article → префикс article →
префикс name → name contains. Возвращает QuickSearchItem с stockQty
по storeId (если передан) или сумме по всем складам.

GET /api/catalog/products/by-barcode/{value}?storeId= — точный поиск
для сканера. 404 если 0 совпадений, объект QuickSearchItem если 1,
{ items: [...] } если несколько (для диалога выбора).

Why: новый UX inline-добавления строк в приёмке требует быстрого
поиска по штрихкоду/артикулу/названию с показом остатков прямо в
дропдауне; полный /products endpoint слишком тяжёлый.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:53:07 +05:00
nns 28b264f43b ui(product-card): «Закупка» и «Цены продажи» в две колонки на десктопе
Внутри секции «Цены» теперь двухколоночная сетка (lg:grid-cols-2):
закупка слева, цены продажи справа, с вертикальным разделителем.
На узких экранах (<lg) колонки складываются вертикально, как раньше.

В правой колонке цены продажи переведены на стандартный <Field>
с label сверху, чтобы выравниваться с полями закупки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:35:29 +05:00
nns 168b12345d fix(supply): колонка «Розничная» использует имя системного PriceType
В таблице позиций приёмки заголовок «Розничная (карточка)» теперь берёт
имя из справочника типов цен (priceTypes.find(isSystem)) — так чтобы
название совпадало с тем, что отображается в карточке товара и в
самом справочнике. Если системного типа нет — фолбэк на IsRetail,
а затем «Розничная».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:29:38 +05:00
nns b7288bac1b feat(supply): «Проведено» внутри формы + обязательная дата и ≥1 позиция
UI:
- Чекбокс «Проведено» переехал из шапки в секцию «Реквизиты документа»,
  чтобы было визуально как в сторонняя система. С хинтом «только проведённый
  документ влияет на остатки и себестоимость».
- Поле «Дата» помечено как обязательное (звёздочка + required).
- canSave требует form.lines.length > 0; пустое состояние секции
  «Позиции» теперь красное «должна быть хотя бы одна позиция».
- onError приёмки достаёт сообщение из response.data.error.

API:
- Create/Update приёмки 400-ят без позиций
  («Приёмка должна содержать хотя бы одну позицию.»).
- Post (проведение) уже валидирует это; теперь и на этапе сохранения.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:29 +05:00
nns 72e602f4ca fix(migrations): catch-up Phase3b_AddShowDescriptionOnProduct
Колонку organizations.ShowDescriptionOnProduct логически вводила миграция
Phase3b_DropProductShelfLifeDays, но я дописал её туда задним числом
после того как миграция уже применилась на стенде. EF проверяет только
__EFMigrationsHistory.MigrationId, не содержимое — поэтому колонка
не создалась, а после деплоя API падал в DevDataSeeder с
«column o.ShowDescriptionOnProduct does not exist».

Правильное лекарство — отдельная догоняющая миграция с idempotent
ALTER TABLE … ADD COLUMN IF NOT EXISTS. На стенде колонка уже добавлена
вручную и запись в истории миграций есть; пайплайн пройдёт мимо неё.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:10:36 +05:00
nns 2321010608 feat(ui): inline-create option in searchable Select
Опциональный onCreate(label) пропс — если задан и пользователь набрал
текст, не совпадающий ни с одним пунктом списка, в дропдауне появляется
кнопка «Создать «query»». По клику колбэк создаёт сущность на сервере
и возвращает id, который сразу подставляется как выбранное значение.
Enter в пустом результате тоже триггерит создание.

Подключено в приёмке для поля «Поставщик» — POST в counterparties с
дефолтами (Type=LegalEntity, остальные поля null), затем invalidate
лукапа. Полные реквизиты редактируются позже в справочнике.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:04:42 +05:00
nns 196658e548 feat(ui): searchable Select component (drop-in)
Заменили нативный <select> на кастомный комбобокс с поисковой строкой:
кликабельная кнопка-триггер, dropdown с input «Поиск…» и фильтрацией
по подстроке, навигация ↑/↓/Enter/Esc. API совместим — onChange получает
synthetic event с e.target.value, поэтому все 22 существующих <Select>
работают без правок call site. Дочерние <option> парсятся в options
автоматически (поддержка <optgroup>).

Why: справочники быстро растут (поставщики, страны, типы цен) — выбор
из длинного списка через нативный select утомителен. Поиск нужен везде.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:02:29 +05:00
nns 306153d128 phase3b: product card cleanup + supply form simplification
Product card:
- Barcodes moved inside «Основное», before description.
- Description hidden behind new ShowDescriptionOnProduct setting (default false).
- «Закупка» и «Цены продажи» объединены в один блок «Цены».

Supply (приёмка):
- Удалены поля «Дата накладной» и «№ накладной поставщика»
  (избыточны: дата документа уже есть, номер можно положить в Notes).
- Поле «Склад *» скрывается если в системе всего один склад
  — для большинства мелких магазинов лишний клик не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:00:06 +05:00
nns eaf5b7399b feat(product-card): drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide
- Удаление поля «Срок годности (дней)»:
  • Domain.Product.ShelfLifeDays убран,
  • миграция Phase3b_DropProductShelfLifeDays — DROP COLUMN,
  • DTO/Input/UI/фильтр в списке товаров — выпилены.

- Перекомпоновка секции «Классификация» в карточке товара:
  • ряд 1 (3 col): Группа * | Единица измерения * | Фасовка,
  • ряд 2 (2 col): Основной поставщик | Страна происхождения,
  • Страна происхождения видна только если включена настройка
    Organization.ShowCountryOfOriginOnProduct (default false).
  • Та же миграция добавляет колонку, в OrganizationSettingsPage
    появляется галка «Показывать «Страну происхождения» на товаре»
    с подсказкой про импорт.

- Артикул теперь обязательное поле с авто-генерацией:
  • ProductEditPage: метка «Артикул *», required,
  • генератор generateArticle() (timestamp[-6] + 3 random) — у нового
    товара поле сразу заполнено,
  • canSave требует непустой article. Уникальность подтверждает
    сервер (он также имеет свой fallback-генератор max+1).

- Иконка корзины в секции «Штрихкоды» рендерится только при
  form.barcodes.length > 1 — для единственной строки удаления нет
  (минимум 1 штрихкод обязателен, удалять единственный нельзя).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:05 +05:00
nns fea3498b8b fix(price-types): IsRequired применяется сразу, без перезагрузки страницы
Баг: переключение «Обязательная» в /catalog/price-types не приводило
к валидации в карточке товара — Save шёл и проходил, не требуя цену.

Корневая причина — два разных queryKey:
- useCatalogMutations в PriceTypesPage инвалидирует
  ['/api/catalog/price-types'] (для самой List-страницы),
- но usePriceTypes-хук, которым пользуется ProductEditPage и
  ProductsPage, живёт под ключом ['lookup:price-types'].
В итоге свежий снапшот справочника не доходит до потребителей.

Фикс:
- useLookups: staleTime=0 + refetchOnMount: 'always' +
  refetchOnWindowFocus=true. Любой переход на ProductEditPage
  делает свежий GET и видит актуальный IsRequired/IsRetail.
- PriceTypesPage save/delete: дополнительно вызывают
  qc.invalidateQueries({ queryKey: ['lookup:price-types'] }) —
  потребители тут же перерендериваются, без необходимости F5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:38:15 +05:00
nns 4649a624c3 chore(price-types): drop IsDefault flag + rename IsRetail label + uniqueness
PriceType: убран флаг IsDefault — он семантически дублировал IsSystem
(защищённая запись «по умолчанию»). Остаются IsSystem / IsRequired /
IsRetail.

- Domain.Catalog.PriceType: удалено поле IsDefault.
- Миграция Phase3b_DropPriceTypeIsDefault: DROP COLUMN.
- DTO/Input (PriceTypeDto, PriceTypeInput) — без IsDefault.
- PriceTypesController:
  • убрана логика uniqueness IsDefault на Create/Update,
  • IsRetail теперь enforce'ит уникальность: при установке IsRetail=true
    у других записей сбрасывается,
  • при удалении единственной IsRetail записи (если она не системная)
    IsRetail автоматически переезжает на IsSystem-запись — у организации
    всегда остаётся один POS-кандидат.
- ProductsController.RecalcRetail и SuppliesController.SetDefaultRetail —
  поиск дефолтной розничной идёт по IsSystem → IsRetail → SortOrder → Name
  (ранее ThenByDescending(IsDefault) — выпилено).
- DevDataSeeder: поле IsDefault убрано.
- web types.ts: убрано isDefault из PriceType.
- PriceTypesPage:
  • убран чекбокс «По умолчанию»,
  • лейбл «Розничная (используется на кассе)» → «Используется на кассе»,
  • Form/blankForm/onRowClick без isDefault.
- ProductsPage / ProductEditPage: фоллбэк дефолтной цены теперь
  IsSystem → IsRetail → первая.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:15:29 +05:00
nns a8717897b7 fix(product-edit): человечная ошибка 400 + блок Save при незаполненных IsRequired ценах
Сервер 400-нул сохранение товара когда обязательная цена пуста, а UI
показывал «Request failed with status code 400» без указания причины.

- onError save mutation: достаём response.data.error из axios-ответа
  и кладём в setError; общий generic message остаётся как fallback.
- canSave дополнен проверкой что у каждого PriceType с IsRequired=true
  есть строка в form.prices с amount > 0 (та же что делает бэкенд,
  чтобы кнопка не отправляла запрос обречённый на 400).
- Под секцией цен — красная подсказка-список незаполненных обязательных
  типов цен («Заполни обязательные цены: «Розничная2» (значение должно
  быть больше 0).»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:58:49 +05:00
nns c257ee7e88 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:43:18 +05:00
nns ebdc70bd58 ci(pricetype-fix-ping): одноразовый Telegram-пинг
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:40:35 +05:00
nns db3be5bbca fix(price-types): correct is-system seeder + require value > 0 + system-price filter/sort
Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» с
IsSystem=true в каждой организации, не проверяя что фактически
системной была другая запись (с реальными ценами у товаров).
В итоге IsSystem-замок оказывался не у той записи.

Миграция Phase3b_FixPriceTypeIsSystem (идемпотентная):
- Снимает IsSystem со всех записей.
- Помечает IsSystem=true + IsRequired=true тому PriceType, у которого
  максимум связанных ProductPrice (приоритет — фактически
  использующейся цене); при равенстве — самая старая (CreatedAt ASC).
- Если у организации вообще нет PriceType — создаёт «Розничная цена»
  (IsSystem=true, IsRequired=true).

DevDataSeeder: «Розничная» переименована в «Розничная цена», добавлены
IsSystem=true / IsRequired=true; работает только если у организации
ноль PriceType — больше не шлёпает дубль.

API валидация (ProductsController.Create/Update):
- FindMissingRequiredPriceAsync: для каждого PriceType с IsRequired=true
  проверяет, что в input.Prices есть запись с Amount > 0. Иначе
  возвращает 400 «Цена «<имя>» обязательна и должна быть больше 0.».

API фильтр+сортировка по системной цене:
- ProductsController.List: query parameters systemPriceFrom / systemPriceTo
  применяют ≥ / ≤ к Prices.Where(IsSystem).Amount.
- Sort key 'systemPrice' — OrderBy / OrderByDescending по той же
  системной цене.

Web ProductsPage:
- Filters.referencePriceFrom/To → systemPriceFrom/To, бэк-параметры
  systemPriceFrom/To.
- Подпись фильтра — динамическое имя системного PriceType (имя из
  справочника, обновляется при переименовании).
- Колонка системной цены получила sortKey='systemPrice'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:31:31 +05:00
nns d1ebbef671 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:09:18 +05:00
nns 126ff97a11 ci(phase3b-ping): одноразовый Telegram-пинг
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:06:40 +05:00
nns c7a498cf9a feat(product-prices): inputs по справочнику PriceType — без dropdown'a
Раньше каждая цена в карточке товара рендерилась как dropdown «выбор
PriceType» + поле ввода + кнопка удаления. Это было избыточно:
типы цен и так фиксированы справочником, выбирать нечего.

Теперь:
- Идём по справочнику PriceType (отсортирован по SortOrder→Name).
- На каждый PriceType — одна строка: label = pt.Name, поле MoneyInput.
- IsRequired запись помечается красной звёздочкой * после имени.
- Стерев значение — строка убирается из form.prices (UI пусто).
- Введя значение — создаётся новая запись (currency = KZT
  fallback из справочника), либо обновляется существующая.
- Кнопка «+ Добавить» и иконка удаления убраны — управление набором
  типов цен теперь только через «Настройки → Типы цен».

addPrice/removePrice вспомогательные функции удалены за ненадобностью.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:55:56 +05:00
nns d2160f8910 feat(percent-input): компонент + inline-наценка в таблице групп
- Field.tsx: новый компонент PercentInput (брат MoneyInput) — только
  цифры + точка/запятая, до 2 знаков после, суффикс «%». На onBlur
  нормализуется в Math.round(n * 100) / 100. null = пусто.
  Использует тот же draft-pattern что MoneyInput, чтобы при наборе
  «12.» точка не пропадала.
- ProductGroupsPage:
  • поле «Наценка %» в модалке заменено на PercentInput,
  • в колонке «Наценка» таблицы теперь inline-PercentInput с
    автосохранением через PUT /api/catalog/product-groups/{id} и
    инвалидацией листинга после ответа сервера. Click stopped — клик
    в инпут не открывает модалку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:54:25 +05:00
nns ba7de0b513 feat(product+filters): срок годности (shelfLifeDays) + фильтр от/до
ProductEditPage:
- В секции «Основное» добавлено поле «Срок годности (дней)» рядом с
  Артикулом — NumberInput, целое ≥ 0, hint «не обязательное поле».
- form.shelfLifeDays хранится строкой и сериализуется в payload как
  number | null.

ProductsPage filters:
- Добавлен диапазон «Срок годности (дней) — от / до».
- Удалён остаток фильтра isActive (поле выпилено в Phase3b foundation).

ProductsController.List:
- Принимает shelfLifeDaysFrom / shelfLifeDaysTo (int?) и применяет
  ≥ / ≤ к p.ShelfLifeDays.
- Также теперь принимает referencePriceFrom / referencePriceTo (новые
  имена); старые purchasePriceFrom/To работают как алиасы для
  обратной совместимости с уже отрендеренным UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:52:44 +05:00
nns b257ea528d feat(supply+products-list): чекбокс «Проведено» с confirm + системная розничная в списке
Supply edit page:
- Кнопки «Провести / Отменить проведение» заменены на чекбокс «Проведено»
  у заголовка. При попытке отметки — confirm «После проведения товары
  будут оприходованы на склад. Продолжить?», при снятии — confirm
  «Снять проведение? Остатки откатятся, себестоимость останется
  (пересчитать вручную при необходимости).».
- Чекбокс disabled пока хотя бы одна строка пустая (Quantity ≤ 0
  или UnitPrice ≤ 0) или вообще нет строк.
- Backend (post/unpost) уже корректно делает StockMovement и
  пересчёт цен из Phase3a — UI просто переключает status.

Products list:
- Колонка «Эталонная цена» заменена на колонку системной розничной.
  Заголовок = Name той PriceType что IsSystem=true (если пользователь
  переименовал «Розничная цена» → «Продажная цена», заголовок
  колонки автоматически становится «Продажная цена»).
- Значение = Product.Prices[ системного PriceType ].Amount.
  Если у товара нет такой записи — «—» (тире).
- Подпись фильтра «Закупочная цена» → «Эталонная цена» (поведение
  фильтра по диапазону цены остаётся прежним).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:49:42 +05:00
nns b79c71591d feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired
Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
  counterparties / price_types (включая индекс
  IX_products_OrganizationId_IsActive). В этих сущностях концепт
  деактивации не оправдан — если товар/группа/единица/контрагент
  не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
  всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
  заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
  не удаляется и IsRequired всегда true; имя редактируется.
  В каждой организации гарантируется одна системная запись
  «Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.

API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
  CounterpartiesController/PriceTypesController: убраны параметры
  isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
  IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
  IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.

Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
  Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
  Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
  OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
  удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
  системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.

UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:46:34 +05:00
nns 9c0e9494f3 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:24:20 +05:00
nns 6729a390bf ci(pricing-model-ping): одноразовый Telegram-пинг что Phase3a задеплоен
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:21:21 +05:00
nns 7fe30bd98d feat(web): supply line retail override column
В таблице строк приёмки добавлена колонка «Розничная (карточка)».
- Значение по умолчанию — текущая дефолтная розничная цена товара
  (берётся из ProductDto.prices при подборе или из SupplyLineDto.
  currentRetailPrice при загрузке существующего документа).
- Любая ручная правка ставит retailPriceManuallyOverridden=true и
  записывает retailPriceOverride. При проведении документа этот
  override применяется к Product.Prices[default] вместо автонаценки.
- В payload PUT/POST шлём retailPriceManuallyOverridden и
  retailPriceOverride (либо null если override снят).
- types.SupplyLineDto расширен полями currentRetailPrice /
  retailPriceManuallyOverridden / retailPriceOverride.
- В addLineFromProduct unitPrice fallbacks теперь учитывают cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:12:12 +05:00
nns a5f0fb83d8 feat(web): price types CRUD visibility + group markup table
- ProductGroupsPage: колонка «Наценка %» в таблице, поле «Наценка %»
  в модалке редактирования с подсказкой про формулу
  ⌈Cost × (1 + наценка/100)⌉.
- НОВАЯ страница /settings/group-markups (GroupMarkupsPage) — массовая
  правка % наценки по группам. Inline TextInput, считается diff,
  кнопка «Сохранить (N)» делает PUT по каждой изменённой группе.
- AppLayout: меню «Типы цен» прячется когда multiplePriceTypesEnabled=false.
  Добавлен пункт «Настройки → Наценки по группам».
- App.tsx: новый маршрут /settings/group-markups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:09:10 +05:00
nns e74bec3964 feat(web): product card pricing UI + settings toggles
- OrganizationSettingsPage: 2 новые галки —
  «Несколько типов цен (Опт, VIP и т.п.)» (multiplePriceTypesEnabled)
  и «Показывать «Эталонную цену» на товаре» (showReferencePriceOnProduct).
- ProductEditPage:
  • «Эталонная цена» с подписью «не обязательное поле»; рендерится только
    при showReferencePriceOnProduct=true.
  • «Себестоимость» — readonly MoneyInput, всегда виден; подпись
    «расчётная (скользящее среднее)».
  • Заголовок секции цен меняется: «Цены продажи» при multipleEnabled,
    иначе «Розничная цена».
  • Кнопка «Привести к себестоимости» (только для existing товара) —
    POST /api/catalog/products/{id}/recalc-retail. После 200 — обновляем
    дефолтный PriceType в form.prices, инвалидируем кэш. После 400 —
    показываем сообщение в общий error.
- useOrgSettings.ts: добавлены поля multiplePriceTypesEnabled и
  showReferencePriceOnProduct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:05:56 +05:00
nns de23f5fc7a feat(api): recalc-retail endpoint + 30-day reference price refresh job
POST /api/catalog/products/{id}/recalc-retail (Admin/Manager/Storekeeper):
- Если у Group товара задан MarkupPercent — записывает в дефолтный
  розничный PriceType значение ceil(Cost * (1 + pct/100)). Округление
  под AllowFractionalPrices: до сотых при включённом, до целого иначе.
- Возвращает 400 «У группы не задана наценка. …» если MarkupPercent null.
- Возвращает 400 если нет ни одного активного PriceType.
- Использует Organization.DefaultCurrencyId как fallback при создании
  новой записи цены.

Background/ReferencePriceRefreshJob (IHostedService, PeriodicTimer 24ч):
- Раз в сутки находит товары с LastSupplyAt < now-30d и Cost > 0,
  переписывает ReferencePrice = Cost, обновляет ReferencePriceUpdatedAt.
- IgnoreQueryFilters — работает над всеми organizations.
- Стартовая задержка 5 минут чтобы не пересечься с пендинг-миграцией.
- Зарегистрирован через AddHostedService в Program.cs.
- Hangfire не подключаем как полноценный server — IHostedService даёт
  тот же эффект без отдельной schema/dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:03:44 +05:00
nns 38040b4ec7 feat(api): supply posting hook for cost & markup
При проведении приёмки (POST /api/purchases/supplies/{id}/post):

- Себестоимость товара пересчитывается по скользящему среднему по
  ВСЕМ складам организации:
  newCost = (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in).
  При qty_old = 0 или cost_old = 0 → newCost = price_in.
  Хранится с 4 знаками (Math.Round AwayFromZero).
- ReferencePrice автозаполняется UnitPrice'ом первой Posted приёмки.
- LastSupplyAt = UtcNow.
- Розничная (дефолтный PriceType, IsDefault → IsRetail → SortOrder/Name):
  • если у строки RetailPriceManuallyOverridden=true и есть
    RetailPriceOverride — пишем его как розничную (override per-line),
  • иначе если у Group задан MarkupPercent — пишем
    Math.Ceiling(cost * (1 + pct/100)) с округлением:
    при AllowFractionalPrices=true — до сотых, иначе до целого,
  • иначе — розничная не трогается.
  Если в Product.Prices ещё нет записи под дефолтный PriceType —
  создаётся (currency = supply.CurrencyId).
- Всё в одной транзакции, ApplyMovementAsync вызывается ПОСЛЕ
  расчёта Cost (currentQty снимается до приёмки).

SupplyLineInput/SupplyLineDto расширены полями RetailPriceManuallyOverridden,
RetailPriceOverride; в DTO дополнительно CurrentRetailPrice — текущая
дефолтная розничная цена товара (для отображения в UI приёмки).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:02:00 +05:00
nns 6acf6b7c03 feat(domain): pricing model rename and new fields (Phase3a)
Подготовка к новой модели цен сторонняя система-style:
- Product.PurchasePrice → ReferencePrice (справочная закупочная,
  не обязательная). + ReferencePriceUpdatedAt для 30-дневного таймера.
- Product.+ Cost numeric(18,4) — себестоимость по скользящему среднему.
- Product.+ LastSupplyAt — UTC последней Posted приёмки.
- ProductGroup.+ MarkupPercent (5,2) — % наценки на cost для авто-розничной.
- Organization.+ MultiplePriceTypesEnabled (default false) и
  ShowReferencePriceOnProduct (default true).
- SupplyLine.+ RetailPriceManuallyOverridden + RetailPriceOverride —
  отметка ручной правки розничной в строке приёмки.

Миграция Phase3a_PricingModel: RENAME + AddColumn'ы. Logic перерасчёта
себестоимости, автонаценки, recalc-endpoint и Hangfire job — следующими
коммитами.

DTO/контроллеры/OtherSystem-импорт/UI поля переименованы в referencePrice
(включая фильтры списка товаров). UI-логика следующего коммита будет
показывать Cost и кнопку «привести розничную к себестоимости»; пока
referencePrice работает как раньше.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:59:09 +05:00
nns b8fd5ec2bd chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:36:14 +05:00
nns a594a433d4 ci(products-fix-ping): одноразовый Telegram-пинг что save товара починен
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:33:52 +05:00
nns fd2da58ad4 fix(products/update): merge barcodes/prices по ключу + 409 на concurrency
Юзер ловил 500 «DbUpdateConcurrencyException: 0 rows affected» при PUT
/api/catalog/products. RemoveRange(всех детей) + Add новых на каждом
сохранении генерирует массовый DELETE/INSERT, при котором EF ожидал N
rows affected, а реальный DELETE возвращал меньше — и весь батч падал
с 500.

Чиню по-человечески:
- Merge by stable key: barcodes по Code, prices по PriceTypeId.
  Совпавшие — обновляем поля, лишние удаляем, новые добавляем. Минимум
  записей в SaveChanges, минимум поводов для 0-affected.
- Catch DbUpdateConcurrencyException → 409 «Товар изменён в другом
  окне или сессии. Перезагрузите страницу и попробуйте снова.» вместо
  непрозрачного 500.
- Удалена мёртвая ветка `if (input.Vat is null) e.Vat = existingVat`:
  Apply уже не присваивает Vat при null, ничего восстанавливать не
  нужно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:18:23 +05:00
nns ad05f9fe30 chore: remove one-shot ping workflow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:47:15 +05:00
nns aec5ca5591 ci(runner-fix-ping): одноразовый Telegram-пинг что стенд догнал
Триггер только на изменение самого файла. Уйдёт следующим коммитом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:45:04 +05:00
nns 3c576934c7 ci(docker): откатить buildx → docker build (registry connect refused внутри builder)
buildx --driver docker-container запускает builder в изолированном
сетевом namespace, откуда 127.0.0.1:5001 (host registry) недоступен:
ошибка «dial tcp 127.0.0.1:5001: connect: connection refused» в шаге
FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0.

Откатываю на классический `docker build` + `docker push`. У host
docker daemon уже есть 127.0.0.1:5001 в insecure-registries, layer-cache
демона между сборками сохраняет dotnet restore / pnpm install при
стабильных манифестах. Path-фильтры (api vs web) остаются — это
основной выигрыш по времени.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:39:29 +05:00
nns 8d9cd201b4 ci(docker): split into docker-api.yml + docker-web.yml — независимые pipeline'ы
В предыдущей попытке runner v6.2.2 не подружился с cross-job outputs —
job changes падал «'runs-on' key not defined in Docker Images/changes»
и оба следующих job уходили в skipped. Откатываю на надёжный путь:
два отдельных workflow с paths-фильтром на уровне триггера.

- docker-api.yml: триггерится на src/food-market.api/**,
  application/**, domain/**, infrastructure/**, shared/**, sln,
  Dockerfile.api, compose. Билдит buildx с registry-cache, пуллит
  и пересоздаёт ТОЛЬКО api (`docker compose up -d --no-deps api`).
- docker-web.yml: триггерится на src/food-market.web/**, Dockerfile.web,
  nginx.conf, compose. Делает то же для web.
- .env стенда теперь идемпотентный — оба тэга всегда :latest, после
  push образа buildx обновляет latest, pull тянет свежий.
- Telegram отдельные сообщения «stage api deployed» / «stage web
  deployed» с SHA — понятно что именно прилетело.

Push, который не задевает ни api/ ни web/ исходники (только md/docs),
вообще не запускает workflow — экономия очевидна.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:22:31 +05:00
nns 9077c07584 fix(money-input): toFixed(2) при allowFractional=true для правильного отображения
formatFromValue использовал String(v) для дробного режима, поэтому
целые значения в БД (1317, 1860) показывались как «1317» / «1860»
даже при включённой галке «Разрешить дробные цены». Теперь:
- fractional=true → v.toFixed(2): 1317 → «1317.00», 1317.5 → «1317.50»;
- fractional=false → String(Math.round(v)): 1317.5 → «1318».

Хелпер используется в init useState, useEffect-синке и commitDraft —
таким образом при onBlur поле всегда возвращается к корректному
формату «1317.00».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:55:33 +05:00
nns 71b749fb35 ci: path filters + buildkit cache для ускорения сборки
Цель: типичный push должен катиться за 30-60 сек вместо 3-5 мин.

docker.yml — две оптимизации:
1. Job changes детектит что изменилось (api/web) через `git diff
   HEAD~1 HEAD`. Образ пересобирается только если затронуты его
   директории; `paths-ignore` отсекает docs/*.md/.github/**.
2. Сборка через `docker buildx build` с registry-cache:
   --cache-from / --cache-to type=registry,ref=...:buildcache,mode=max.
   Локальный 127.0.0.1:5001 уже разрешает DELETE, так что mutable
   buildcache работает. dotnet restore / pnpm install теперь почти
   мгновенные при отсутствии изменений в *.csproj / pnpm-lock.yaml.
3. deploy-stage запускается только если api или web реально
   пересобирался; для пропущенного образа в .env пишется :latest,
   compose pull тянет последний успешный slim. Telegram-сообщение
   указывает что именно деплоилось ([api web] / [web] / только compose).

ci.yml — actions/cache для NuGet (~/.nuget/packages по hash *.csproj)
и pnpm store (по hash pnpm-lock.yaml). paths-ignore такое же.

POS-job (windows-latest) не трогаем — он и так fires только на
тег v* / workflow_dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:54:57 +05:00
nns 87271281b0 fix(money-input): сохранять промежуточный ввод точки в draft
Реальная причина бага: classic problem controlled numeric input.
Когда юзер печатал «100.» (хотел «100.50»), цикл value→Number("100.")→100
→ display=String(100)=«100» съедал точку, и продолжать ввод дроби
становилось невозможно.

Фикс: MoneyInput хранит локальный draft string, который и показывается
в input. Снаружи value всё равно прокидывается числом, но draft не
синхронизируется с ним пока поле в фокусе. Промежуточные состояния
типа «100.» теперь живут в draft и не теряются.

- Добавлены useState<draft> и useState<focused>.
- onChange: пишем в draft as-is (только фильтр символов и одна точка),
  наружу onChange(number) отдаём сразу когда draft парсится в число
  (включая случай «100.» → отдаём 100, но draft оставляем «100.»).
- onBlur: commitDraft нормализует draft и в number, и обратно в draft.
- useEffect синхронизирует draft с value только когда !focused.
- Округление при !fractional не выполняется во время focus — иначе
  перебивает ввод пользователя.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:44:18 +05:00
nns 7d86b7ed73 fix(money-input): корректное обновление allowFractionalPrices без перелогина
Главная причина бага: useOrgSettings имел staleTime=5 минут. Когда
пользователь переключал галку в /settings/organization, инвалидация
ключа `/api/organization/settings` помечала кэш stale, но MoneyInput
в открытой форме товара продолжал получать старое значение из кэша
до момента, пока сабскрайбер не делал перефетч. На свежем монтировании
(переход с /settings обратно на /catalog/products/...) перефетча тоже
не происходило, т.к. данные считались всё ещё валидными.

Фикс:
- useOrgSettings: staleTime=0 + refetchOnMount: 'always' +
  refetchOnWindowFocus=true. Каждое монтирование компонента — свежие
  настройки одним лёгким GET'ом. Цена нулевая, цикл «изменил настройку
  — открыл форму — увидел эффект» теперь работает без перезагрузки.
- OrganizationSettingsPage: useEffect синхронизирует form со settings.data
  без условия `!form` — при свежем рефетче форма тоже подтягивает актуальное.

Все вызовы MoneyInput в формах уже не передают allowFractional —
компонент сам читает useOrgSettings, что вместе с фиксом выше даёт
корректное поведение.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:39:25 +05:00
nns 8a0f8c20f9 fix(money-input): уважать AllowFractionalPrices в формах редактирования
MoneyInput теперь сам читает useOrgSettings().allowFractionalPrices,
а не только полагается на prop из вызова. Это закрывает два бага:

1. Когда настройка известна и запрещает дробное, но в state товара
   лежит дробная цена (например исторические данные из OtherSystem) —
   useEffect синхронизирует округлённое значение наружу, чтобы
   при сохранении не уходило значение, которого юзер не видит.

2. Пока org.data ещё не загружено, MoneyInput не режет дробь
   (fractional трактуется как true до приезда настройки), иначе
   в момент гидрации формы из ProductDto с дробной ценой компонент
   успевал её обрезать до того как настройка приходила.

Все вызовы MoneyInput в ProductEdit / SupplyEdit / RetailSaleEdit /
ProductsPage filters очищены от избыточного prop allowFractional —
компонент берёт настройку сам. Override через prop остаётся доступным.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:30:00 +05:00
nns 53fa4d2deb feat(barcode-uniqueness): pre-check на Create/Update + warnings импорта + admin endpoint
Pre-check:
- ProductsController.FindBarcodeConflictAsync ищет штрихкоды,
  принадлежащие другим товарам организации; на Create/Update при
  конфликте возвращается 400 «Штрихкод 1234 уже используется
  товаром «Кока-кола 0.5л».» вместо 500 от unique index.

OtherSystem-импорт:
- При попытке привязать уже занятый штрихкод — пишется warning
  «{товар}: штрихкод {код} уже занят, пропущен.» в errors[],
  товар остаётся, дубль не сохраняется.
- В конце импорта проходит финальный SELECT по дубликатам в БД
  (если есть исторические) — warnings типа «Внимание: штрихкод X
  привязан к нескольким товарам — почисти вручную.».

Admin-endpoint:
- GET /api/catalog/products/barcode-duplicates (Admin/Manager)
  возвращает массив { code, products: [{productId, productName,
  article}, ...] } для будущей UI-чистки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:26:20 +05:00
nns d1a7e1e647 ui(products-list): убрать фильтр «Со штрихкодом», добавить «Закупочная цена от/до»
Поскольку штрихкод теперь обязательный (минимум 1 у каждого товара),
фильтр «Со штрихкодом» бессмыслен — убран из UI и контроллера.

Вместо него — два MoneyInput «Закупочная цена от/до» в панели
фильтров. Использует символ валюты по умолчанию из настроек
организации и уважает AllowFractionalPrices.

Backend: ProductsController.List принимает purchasePriceFrom /
purchasePriceTo (decimal?), применяет ≥ / ≤ к PurchasePrice;
параметр hasBarcode удалён.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:23:45 +05:00
nns 4f4a751d26 feat(org-settings): AllowFractionalPrices — переключатель дробных цен
Новая галка в настройках магазина «Разрешить дробные цены (с копейками)»
(default false). Когда выключено — все денежные поля принимают и
сохраняют только целые числа.

- Organization.AllowFractionalPrices + миграция Phase5h.
- OrgSettings DTO/Input + UI настроек (галка с подсказкой).
- MoneyInput получил prop allowFractional: при false запрещает ввод
  точки/запятой и форматирует целым числом, при true — две цифры
  после запятой как раньше.
- ProductEditPage / SupplyEditPage / RetailSaleEditPage передают
  org.allowFractionalPrices во все MoneyInput.
- Списки Products / Supplies / RetailSales форматируют суммы по
  настройке (с .00 или без).
- Сервер защищён от обхода UI: ProductsController / SuppliesController /
  RetailSalesController при сохранении округляют purchasePrice /
  price.amount / unitPrice / discount / paidCash / paidCard до целого
  если флаг выключен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:21:04 +05:00
nns 38f7725593 feat(product): группа обязательна, ≥1 штрихкод, умные дефолты на новом
- Product.ProductGroupId теперь NOT NULL (Guid вместо Guid?). Миграция
  Phase5g_RequiredProductGroup делает backfill: создаёт «Продукты
  питания» в каждой организации, у которой есть товары без группы,
  переносит туда null-значения, потом ALTER COLUMN NOT NULL.
- ProductDto/ProductInput: ProductGroupId/Name без `?`.
- ProductsController.Create/Update: 400 если barcodes пустой.
- OtherSystem-импорт: при отсутствии productFolder у товара ставится
  defaultGroupId — id «Продукты питания» (создаётся при необходимости).
- DemoCatalogSeeder: «Продукты питания» добавлена в seed-набор групп.
- ProductEditPage:
  • новый товар сразу получает 1 EAN-13 в barcodes,
  • Single-select Единица измерения и Группа лишились опции «—»,
  • дефолт unitOfMeasureId — id единицы code='796' (штука),
  • дефолт productGroupId — «Продукты питания» (или первая),
  • Save disabled пока имя/единица/группа/≥1 штрихкод не заполнены,
  • если штрихкоды удалены — красная подсказка вместо нейтральной.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:24:10 +05:00
nns 23e29be21b feat(forms): MoneyInput/NumberInput + select-пагинация + Range на бэкенде
UI:
- Pagination: ввод страницы заменён на select (option 1..totalPages),
  по выбору сразу setPage. Стрелки ← → остаются.
- Field.tsx: добавлены MoneyInput (decimal + суффикс ₸/$/€) и
  NumberInput (decimal без валюты). Оба фильтруют ввод регулярно
  (только цифры + точка/запятая→точка), при focus — выделяют значение.
- ProductEditPage: purchasePrice / vat / minStock / maxStock / amount
  в ценах продаж переведены на новые компоненты; символ валюты —
  из выбранной валюты позиции/закупки или из defaultCurrencySymbol орг.
- SupplyEditPage / RetailSaleEditPage: quantity/unitPrice/discount
  в строках, paidCash/paidCard в шапке — на NumberInput/MoneyInput
  с символом из form.currencyId.
- CountriesPage: vatRate — NumberInput.

API:
- ProductInput / ProductPriceInput / SupplyLineInput /
  RetailSaleLineInput / RetailSaleInput — добавлены [Range(0,1e10)]
  на денежные/количественные поля и [Range(0,100)] на проценты.
  ASP.NET автоматически валидирует и возвращает 400 при выходе.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:17:32 +05:00
nns adf2c90904 chore: remove one-shot pack8-ping workflow
pack8-ping.yml #64 отработал (Telegram уведомление отправлено),
одноразовый workflow больше не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:18:21 +05:00
nns 68ccc9fa1d ui(mobile): адаптация под смартфоны — drawer-меню, grid, модалка
Система теперь корректно работает на узких экранах (<768px):

- AppLayout: на мобиле фиксированный sidebar заменён hamburger-меню
  (Menu icon) + off-canvas drawer с overlay. На md+ — прежний sidebar.
- ProductsPage: дерево групп тоже превращается в drawer на мобиле,
  кнопка «Группы» рядом с заголовком; фильтры flex-wrap.
- Modal: на мобиле (<sm) разворачивается на весь экран (items-stretch,
  min-h-full, убраны скругления и верхний отступ).
- DataTable: мин-ширина 640px + whitespace-nowrap в заголовках, уменьшен
  горизонтальный padding на мобиле. Родительский overflow-auto даёт
  плавный горизонтальный скролл.
- PageHeader/ListPageShell: flex-wrap, меньший padding на мобиле.
- SearchBar: flex-1 на узких (занимает доступное место), фикс 256px на sm+.
- ProductEditPage Grid helper: 3/4 колонки теперь grid-cols-1 sm: 2
  md: 3/4 — поля не слипаются на телефоне.
- ProductEditPage/Supply/RetailSale/Dashboard/OrganizationSettings:
  отступы p-3 sm:p-6, grid grid-cols-2 на страну/валюту → 1 col на мобиле.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:17:56 +05:00
nns 38f117b2a4 ci(pack8-ping): одноразовый workflow для Telegram-пинга
Триггерится только при изменении самого файла — после выполнения
будет удалён следующим коммитом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:11:52 +05:00
nns 6468186ed5 feat(products): авто-генерация числового артикула при создании
При POST /api/catalog/products если Article пустой — сервер берёт
max(Article::int) среди артикулов текущей организации и ставит +1.
Если числовых артикулов нет — «1». Пользователь может указать
артикул руками, тогда используется его значение.

Конфликт по unique index IX_products_OrganizationId_Article на
SaveChanges перехватывается — возвращается 400 с текстом «Артикул
«X» уже занят в этой организации.» вместо 500. Та же обработка
добавлена в Update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:04:57 +05:00
nns 9c70de9b3d ui(products-list): колонки Штрихкод/Фасовка/Закупочная цена
В таблице товаров:
- «Ед.» → «Фасовка» (packagingLabel из типа товара, sort packaging).
- «Штрихкодов» (count) → «Штрихкод» — первый код монoshrift.
- Убраны колонки «Группа» и «Активен».
- Добавлена «Закупочная цена» с форматированием «305.00 ₸»
  (purchasePrice + purchaseCurrencyCode), sort purchasePrice.

На сервере ProductsController.List принимает новые sort keys
packaging и purchasePrice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:03:45 +05:00
nns 9886b5dee1 feat(org-settings): настройка ShowMinMaxStock для мин/макс остатков
Добавлена Organization.ShowMinMaxStock (bool, default false) — флаг
видимости полей «Минимальный / Максимальный остаток» на карточке
товара. В UI настроек магазина появилась соответствующая галка
с подсказкой. По умолчанию выключено — большинству магазинов
эти поля не нужны.

Миграция Phase5f_ShowMinMaxStock добавляет колонку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:02:53 +05:00
nns 195ca2e2bb ui: barcode регенерация / h-10 поля / прыжок на страницу / min-max скрытие
- lib/barcode.ts: новая утилита generateBarcode(type) — валидные коды
  под все форматы (EAN-13 с префиксом 2, EAN-8 с checksum, UPC-A
  с checksum, UPC-E 8 цифр, Code128/Code39 12 буквенно-цифровых).
- ProductEditPage: при смене типа штрихкода в dropdown поле кода
  регенерируется под новый формат.
- Field.tsx: единая высота h-10 и leading-none для TextInput/Select
  чтобы Страна/Валюта/НДС в настройках были одного размера.
  TextArea оставлен с h-auto для multiline.
- Pagination.tsx: рядом с ← → добавлен input «Страница [N] из M»
  для прыжка на произвольную страницу (Enter / blur применяют).
- ProductEditPage: блок мин/макс остатков теперь показывается только
  при org.showMinMaxStock (сама настройка добавится следующим коммитом).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:02:39 +05:00
nns efeeb61e42 refactor(currencies): убрать IsActive и MinorUnit из UI/API
- Currency.IsActive удалён полностью (domain/DTO/API/web/миграция).
  Валюты — глобальный справочник; «архивировать» USD глобально
  бессмысленно, а per-tenant видимости у валют нет.
- MinorUnit остаётся в БД (нужен для форматирования цен), но скрыт
  из UI: убран CurrencyDto.MinorUnit, CurrencyInput.MinorUnit,
  колонка «Знаки» из списка.
- Форма валюты — 3 поля: Код / Название / Символ.
- Миграция Phase5e_DropCurrencyIsActive дропает колонку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:01:13 +05:00
nns 24dc7fc619 refactor(vat): Product.Vat как decimal(5,2), поле видно только при VatEnabled
Единственная роль галки «В том числе НДС» на товаре — показать/скрыть
поле «Ставка НДС %». Никакой семантики «в том числе/сверху» на товаре
не живёт — это логика документа (продажи/поставки).

- Product.Vat: int → decimal (миграция Phase5d_ProductVatDecimal меняет
  тип колонки на numeric(5,2)).
- ProductDto/ProductInput: decimal? Vat.
- ResolveDefaultVatAsync, seeders, OtherSystem import — decimal.
- OtherSystem import: если vatEnabled пришёл — уважаем, иначе прежний
  fallback «vat=0 → без НДС».
- UI: вместо жёсткого Select [0,10,12,16,20] — TextInput number step=0.01;
  поле рендерится только когда form.vatEnabled=true; дефолт для нового
  товара подставляется из Country.VatRate организации.
- В таблице товаров ставка печатается с 2 знаками (16.00%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:51:11 +05:00
nns 781f268089 feat(org-settings): галки «Услуга»/«Маркируемый» скрываются по умолчанию
Добавлены organizations.ShowServiceOnProduct и ShowMarkedOnProduct
(оба default false). В UI карточки товара чекбоксы «Услуга» и
«Маркируемый» рендерятся только если соответствующий флаг включен;
в фильтрах списка товаров Tri-фильтры тоже прячутся. В БД поля
IsService/IsMarked у Product сохраняются как обычно — просто UI их
не показывает.

Это параллель к ShowVatEnabledOnProduct: по умолчанию UI максимально
простой, а нишевые фичи включаются через настройки магазина.

Миграция Phase5c_ShowServiceMarkedOnProduct добавляет обе колонки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:39:06 +05:00
nns 42a3d2aa50 feat(barcode): авто-генерация EAN-13 при добавлении штрихкода
Новый штрихкод в товаре сразу получает валидный EAN-13 с префиксом
"2" (зарезервирован под внутренние штрихкоды магазина, не конфликтует
с GTIN производителей). Пользователь может заменить на реальный
считанный — поле остаётся редактируемым.

Утилита lib/barcode.ts::generateEan13InternalPrefix2() генерирует
11 случайных цифр после "2" и дописывает контрольную сумму EAN-13.

Уникальность штрихкода в организации уже обеспечивает
IX_product_barcodes_OrganizationId_Code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:35:40 +05:00
nns 1227fbdfa5 ui(org-settings): симметричный layout — Страна/Валюта/НДС одной сеткой
Все read-only поля (Валюта, Ставка НДС) теперь единого вида: disabled
TextInput на всю ширину ячейки в grid-cols-2 gap-4, та же высота/padding
что и у editable Select "Страна". Единая подсказка про источник правды
страны вынесена одним параграфом под сеткой.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:34:44 +05:00
nns 5a06e15924 feat(tables): server-side sort by column header click
Во всех таблицах можно сортировать по клику на заголовок столбца:
первый клик — по возрастанию (↑), второй — по убыванию (↓),
смена колонки сбрасывает предыдущую. Без активной сортировки —
серверный default (обычно по Name ASC).

Реализация:
- PagedRequest: добавлены Sort (ключ колонки) и Order ("asc"/"desc"),
  плюс удобное свойство Desc.
- DataTable: Column.sortKey + props sortKey/sortOrder/onSortChange,
  в заголовке появляется иконка (ArrowUpDown/ArrowUp/ArrowDown).
- useCatalogList: хранит sortKey/sortOrder, отдаёт setSort, шлёт
  ?sort=&order= в query-string.
- Все 10 List-эндпоинтов (Countries, Currencies, UnitsOfMeasure,
  PriceTypes, Stores, RetailPoints, Counterparties, ProductGroups,
  Products, Supplies, RetailSales + Stock/Movements) принимают
  параметры и применяют switch-based OrderBy по whitelisted ключам.
- Все страницы со списками прокидывают sort state и sortKey на
  колонках, где сортировка имеет смысл (тексты/числа/даты).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:24:05 +05:00
nns 31d528d5c2 refactor(countries): drop SortOrder, sort by Name, auto-width columns
- Country.SortOrder удалено из домена/DTO/API/seeder/web/UI.
- Миграция Phase5b_DropCountrySortOrder дропает колонку.
- Список стран сортируется по Name ASC.
- В форме: поле «Порядок» убрано.
- В таблице: убрана колонка «Порядок», ширины колонок сжаты по
  содержимому (Код 80px, Валюта 120px, НДС 100px, Название flex).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:14:01 +05:00
nns 5bea852b94 feat(org-settings): валюта read-only, тянется из страны (как НДС)
- Currency в настройках больше не выбирается, показывается disabled
  как "KZT (₸)", источник правды — Country.DefaultCurrencyId.
- Backend: OrgSettingsInput больше не принимает DefaultCurrencyId;
  Update синхронизирует Organization.DefaultCurrencyId со страной.
- UX: страна — единственный редактируемый вход, определяет и НДС, и валюту.
- Мульти-валютный режим (Organization.MultiCurrencyEnabled) остаётся
  галкой; выбор валюты в закупках/продажах/карточке товара по-прежнему
  скрыт когда флаг выключен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:08:30 +05:00
nurdotnet 6979599791 feat(vat): ставка в стране + опц. переопределение на товаре
Phase5_VatAsCountryProperty:
- countries.VatRate (numeric(5,2)) — ставка страны, источник правды.
  Seed: KZ=16, RU=20, BY=20, DE=19, CN=13, TR=18, UZ=12, KG=12, KR=10,
  IT=22, PL=23, US=0.
- organizations.ShowVatEnabledOnProduct (bool, default false) — флаг
  отображения на карточке товара.
- organizations.DefaultVat удалён (заменён страной).
- products.Vat ОСТАЁТСЯ: для KZ есть льготные категории (хлеб/молоко =
  0%) и фискальный чек требует ставку на каждой позиции.

Country domain: + DefaultCurrency / VatRate (уже было DefaultCurrencyId
из Phase4, сейчас дополнено).

Organization domain: DefaultVat убран, ShowVatEnabledOnProduct добавлен.

Backend:
- ProductInput.Vat теперь int? — если UI скрывает поле и прислал null,
  ProductsController берёт дефолт из страны организации (Country.VatRate
  при создании; при update сохраняет прежнее значение).
- CountriesController.List/Get/Create/Update возвращает/принимает
  DefaultCurrency и VatRate.
- OtherSystem импорт: дефолт Vat загружается из страны организации.
- SystemReferenceSeeder: новые валюты BYN/UZS/KGS/TRY/KRW/PLN, seed
  country-currency-vat для всех 12 стран.
- OrganizationSettingsController: VatRate read-only из страны,
  ShowVatEnabledOnProduct редактируется.

Web:
- Country type + CountriesPage форма редактирования (валюта, ставка НДС).
- OrganizationSettingsPage: "Ставка НДС" read-only
  (берётся из страны, ссылка на /catalog/countries), галочка
  "Указывать ставку НДС на товаре".
- ProductEditPage: блок Ставка НДС % + галка "В том числе НДС" теперь
  показываются только если showVatEnabledOnProduct=true. В payload
  при save.mutate отправляется vat=null если скрыто.
- ProductsPage: колонка НДС показывается только при включённом флаге.

Galleries/products/settings других этапов — не задеты.
2026-04-24 11:56:28 +05:00
nurdotnet e16375ccf6 feat(product-images): загрузка на диск сервера + галерея с лайтбоксом
Backend:
- ProductImagesController: GET list / POST multipart upload /
  DELETE / POST set-main.
- Файлы лежат в $ContentRoot/uploads/products/{productId}/{guid}.{ext}
  (volume /opt/food-market-data/uploads:/app/uploads в compose).
- В БД хранится относительный URL /uploads/products/{id}/{file}.
- UseStaticFiles на /uploads — публичная раздача (без auth).
- Допустимые расширения: jpg/jpeg/png/webp/gif, до 10 МБ.
- При первой загрузке картинка становится основной; Product.ImageUrl
  синхронизируется с "основной".
- Удаление основной переводит "основной" флаг на следующую оставшуюся.

Web-nginx: /uploads/ проксируется на api:8080.

Web UI:
- Компонент <ProductImageGallery>: превьюшки 80×80 в грид,
  при наведении — кнопки "сделать основным" и "удалить",
  клик на превью → fullscreen lightbox с навигацией ←→ и счётчиком.
- В ProductEditPage убран инпут "URL изображения" (был технической
  строкой для копипаста), вместо него блок "Изображения" с галереей.
  Показывается только для уже сохранённого товара (есть id).

Docker compose: добавлен bind-mount /opt/food-market-data/uploads.
2026-04-24 11:12:27 +05:00
nurdotnet d93edcae2c feat(product): enum Packaging (штучный/весовой/разливной) вместо IsWeighed
Миграция Phase4b_ProductPackaging:
  products.IsWeighed (bool) → products.Packaging (int enum)
  1=Piece (default), 2=Weight, 3=Liquid
Backfill: прежние весовые товары → Weight.

Domain/DTO/Input/Controller/Seeder/OtherSystemImport — всё обновлено.

Web:
- Packaging enum в types.ts.
- ProductEditPage: select "Фасовка" вместо checkbox "Весовой".
- Подпись чекбокса НДС уточнена: "НДС применяется (ставка выше)" —
  ссылается на поле Vat на товаре.
- Удалён IsMarked checkbox текст → "Маркируемый (Честный знак / Datamatrix)".
- ProductsPage фильтр: select Packaging вместо Tri(IsWeighed).
2026-04-24 11:08:43 +05:00
nurdotnet 337e790eab feat(org-settings): Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек
Миграция Phase4_CountryCurrencyOrgDefaults:
- countries.DefaultCurrencyId (FK → currencies)
- organizations.DefaultCurrencyId, MultiCurrencyEnabled, DefaultVat
- Seed: KZ→KZT, RU→RUB, BY→BYN, US→USD, DE→EUR, CN→CNY, TR→TRY
- Default для org: KZT, vat=16

Backend:
- Organization сущность получила DefaultCurrency/MultiCurrencyEnabled/DefaultVat.
- OrganizationSettingsController: GET/PUT /api/organization/settings.
- DevDataSeeder при создании/backfill орга выставляет KZT + vat=16.

Web:
- /settings/organization: форма с выбором страны (авто-подтягивает валюту),
  чекбоксом multi-currency, ставкой НДС по умолчанию.
- useOrgSettings() хук.
- SupplyEditPage / RetailSaleEditPage / ProductEditPage: select валюты
  показывается только если multiCurrencyEnabled=true, иначе
  подтягивается DefaultCurrency организации и рисуется символ валюты
  справа от цены.
- ProductEditPage при создании нового товара берёт VAT из org.DefaultVat.
- В sidebar добавлен раздел 'Настройки → Организация', убран
  Ставки НДС (сущность удалена раньше).
2026-04-24 11:03:25 +05:00
nurdotnet a8b3ef40ce ui(products): убрать колонку "Тип" из списка, спрятать min/max stock в "Расширенные"
В списке товаров колонка с бейджами Услуга/Весовой/Маркируемый только
занимает место и ничем не помогает — в UX OtherSystem такого нет, убираю.
В форме редактирования минимальный/максимальный остаток (для
уведомлений о пополнении и автозаказа) — второстепенные поля, ушли в
раскрывающийся блок "Расширенные параметры" сразу после блока закупки.
Закупочная цена осталась основной.

НДС-колонка теперь рисует "—" если VatEnabled=false.
2026-04-24 10:53:32 +05:00
nurdotnet a3b3caa2d3 fix(other-system/test): сделать Token опциональным
UI перестал отправлять токен в теле /test (он теперь из настроек),
а TestRequest был с non-null string — ASP.NET model validation отдавал
400 'One or more validation errors occurred'. Сделал nullable.
2026-04-24 00:25:24 +05:00
nurdotnet ce0c3acdd6 feat(other-system-import): async jobs с прогрессом + токен в настройках
Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.

Фиксы:
- Async-job pattern: POST /api/admin/other-system/import-products и
  /api/admin/cleanup/all/async возвращают jobId, реальная работа
  в Task.Run. GET /api/admin/jobs/{id} — статус +
  Total/Created/Updated/Skipped/Deleted/Stage/Message.
- ImportJobRegistry (singleton, in-memory) — хранит job-progress.
- OtherSystemImportService обновляет progress по мере пейджинга
  (в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
  "Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.OtherSystemToken + миграция
  Phase3_OrganizationOtherSystemToken. Endpoints:
  GET/PUT /api/admin/other-system/settings.
- Импорт-endpoints больше не требуют token в теле — берут из org.
- HttpContextTenantContext.UseOverride(orgId) — AsyncLocal-scope
  для background tasks (HttpContext там нет, а query-filter'у нужен
  orgId — ставим через override).

Nginx (host + web-container) получил 60-минутный timeout на
/api/admin/import/ чтобы старый sync-путь тоже не ронять (на
случай если кто-то вернёт sync call).

Web:
- OtherSystemImportPage переработан: блок "Токен API" (save/test
  mask), блок импорта с кнопками без поля токена.
- JobCard с polling каждые 1.5s отображает живые счётчики и stage.
- DangerZone тоже теперь async с live-прогрессом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:11 +05:00
nurdotnet 9ce22dee26 fix(other-system/import): per-page retry + чаще SaveChanges
Почему импорт раньше обрывался на ~9500/29500 товаров:
- StreamPagedAsync бросал исключение при любом сетевом глюке или
  таймауте HttpClient (90s) на одной из страниц и весь цикл сыпался.
- Флаш делался раз в 500 товаров, так что при обрыве на 9500-м можно
  было потерять последние 499.

Фиксы:
- Per-page retry до 5 раз с exp-backoff (2,4,8,16с) — обрабатываем
  только сетевые ошибки (HttpRequestException / TaskCanceledException /
  IOException). API-ошибки типа 4xx проходят наверх как есть.
- SaveChangesAsync теперь каждые 100 товаров вместо 500 — меньше
  вероятность потерять при внезапном обрыве на границе.
- При исчерпании retries — бросаем осмысленное исключение с offset'ом.

Пользователь сейчас имеет 9500 из 29509 товаров (группа "Алкоголь" — 20
из 518). Нужно перезапустить импорт в UI с overwriteExisting=true —
существующие товары обновит, недостающие подтянет.
2026-04-23 23:30:37 +05:00
nurdotnet 15ea4b9e86 feat(catalog/products): tree-of-groups + фильтры как в OtherSystem
Что добавлено:
- Слева дерево товарных групп (рекурсивное, с раскрытием), клик
  переключает фильтр ProductsPage. Клик на "Все товары" — показать весь
  каталог. Выбор группы включает её поддерево (матчинг по Path prefix
  на бэкенде, чтобы сабгруппы тоже попадали в выборку).
- Кнопка "Фильтры" разворачивает верхнюю панель с тумблерами
  (all/да/нет): Активные, Услуга, Весовой, Маркируемый, Со штрихкодом.
  Счётчик в кнопке показывает количество активных не-дефолтных фильтров.
- "Сбросить" очищает всё, кроме группы.

API:
- ProductsController.List: добавлены параметры `isMarked`, `hasBarcode`.
  `groupId` теперь фильтрует по Path-prefix (вся ветка вместо одной
  группы) — это ближе к UX OtherSystem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:18:50 +05:00
nurdotnet c172cfda5e feat(other-system): import archived entities too (as IsActive=false)
Раньше архивных контрагентов/товаров OtherSystem API по умолчанию не
возвращает (default filter = active only). Для полной синхронизации
OtherSystem → food-market теперь делаем 2 прохода: сначала активных
(default), затем filter=archived=true — отдаём всё одним потоком.

В service убран skip-if-archived; archived записи импортируются
c IsActive=false (уже было в ApplyCounterparty / ApplyProduct;
продублировал для product folders: IsActive = !f.Archived).

Клиент: рефакторинг — один generic StreamPagedAsync<T>(path, archivedOnly)
вместо трёх копий постраничного цикла.

Теперь пользовательский OtherSystem-каталог мапится в food-market 1:1
включая архив. Счётчик "Пропущено" отныне значит только "уже существует
и галка Перезаписать не стоит".
2026-04-23 21:35:44 +05:00
nurdotnet 188114d193 fix(db): reconcile stage schema — drop TrackingType, add IsMarked
Phase2c2_OtherSystemAlignment и Phase2c3_MsStrict остались в
__EFMigrationsHistory на стейдже, но .cs-файлы были удалены при откате
кода (654a8ba). В результате:
- снапшот не соответствовал актуальной БД
- колонка TrackingType висела в БД, а код ждал IsMarked
- /api/admin/other-system/import-products валился с 42703

Эта миграция:
1. Добавляет IsMarked bool NOT NULL DEFAULT false
2. Если TrackingType есть — бэкфиллит IsMarked = (TrackingType <> 0)
   и удаляет колонку (idempotent через information_schema check)
3. Auto-scaffold также синхронизировал snapshot (был устаревшим —
   содержал VatRate/IsAlcohol/Kind/Symbol и пр., которых в коде давно нет).

Локально применилось без ошибок.
2026-04-23 21:23:45 +05:00
nurdotnet 405fe62475 feat(admin): temp cleanup buttons + fix OtherSystem import duplicates
Проблема: при импорте контрагентов/товаров с галкой «перезаписать» код
ставил Add() новой сущности вместо Update() существующей, порождая
дубликаты. Исправил оба потока — теперь по ключу (Name для контрагентов,
Article для товаров) ищем существующую запись и обновляем её на месте.
Коллекции (цены/штрихкоды товара) при апдейте не трогаем, чтобы не
затереть ручные правки пользователя.

Временные админские кнопки для разбора последствий прошлых импортов:
- DELETE /api/admin/cleanup/counterparties — сносит контрагентов + зависимые поставки + их stock-movements (RetailSale.CustomerId обнуляется, Product.DefaultSupplierId обнуляется)
- DELETE /api/admin/cleanup/all — сносит всё tenant-scoped (товары/группы/контрагенты/поставки/чеки/остатки/движения). Организация, пользователи, справочники (единицы, страны, валюты, типы цен, склады, точки продаж) остаются.
- GET /api/admin/cleanup/stats — превью с количеством записей.

UI: секция «Опасная зона» внизу страницы /admin/import/other-system с двумя
красными кнопками + подтверждение словом «УДАЛИТЬ». Показываются счётчики
до и что удалилось после.
2026-04-23 20:58:59 +05:00
nurdotnet 71a2d46bf2 deploy: mirror all base images into local registry — builds no longer need internet
Any block on mcr.microsoft.com or docker.io from KZ would stall our
builds. Mirror docker base images into 127.0.0.1:5001 under mirror/*
via daily systemd timer, and point Dockerfiles + compose + CI at the
local copies.

Mirror:
  node:20-alpine                    → 127.0.0.1:5001/mirror/node:20-alpine
  nginx:1.27-alpine                 → 127.0.0.1:5001/mirror/nginx:1.27-alpine
  postgres:16-alpine                → 127.0.0.1:5001/mirror/postgres:16-alpine
  mcr.microsoft.com/dotnet/sdk:8.0  → 127.0.0.1:5001/mirror/dotnet-sdk:8.0
  mcr.microsoft.com/dotnet/aspnet:8.0 → 127.0.0.1:5001/mirror/dotnet-aspnet:8.0

Infra (committed for reproducibility):
- deploy/mirror-base-images.sh — pull/tag/push (idempotent)
- deploy/food-market-mirror-base-images.{service,timer} — daily refresh,
  installed on stage server

Usage in build-time:
- Dockerfile.api/web take ARG LOCAL_REGISTRY=127.0.0.1:5001 with the local
  copy as default, so the same Dockerfile still builds from docker.io if
  you pass --build-arg LOCAL_REGISTRY=docker.io (well, almost).
- docker-compose.yml postgres: image via ${REGISTRY}/mirror/postgres.
- ci.yml postgres service container: local mirror.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:42:48 +05:00
nurdotnet 654a8ba87d feat: strict OtherSystem schema — реплика потерянного f7087e9
Main расходился с БД стейджа (Phase2c3_MsStrict в history, но код ещё ссылался на VatRate etc.) — деплой ломался. Реплицирую удаление сущностей вручную, чтобы код совпадал с таблицами.

Убрано (нет в OtherSystem — не выдумываем):
- Domain: VatRate сущность целиком; Counterparty.Kind + enum CounterpartyKind; Store.Kind + enum StoreKind; Product.IsAlcohol; UnitOfMeasure.Symbol/DecimalPlaces/IsBase.
- EF: DbSet<VatRate>, ConfigureVatRate, Product.VatRate navigation, индекс Counterparty.Kind.
- DTO/Input: соответствующие поля и VatRateDto/Input.
- API: VatRatesController удалён; references в Products/Counterparties/Stores/UoM/Supplies/Retail/Stock.

Добавлено как в OtherSystem:
- Product.Vat (int) + Product.VatEnabled — OtherSystem держит НДС числом на товаре.
- KZ default VAT 16% — applied в сидерах и в OtherSystemImportService когда товар не принёс свой vat.

OtherSystemImportService:
- ResolveKind убран; CompanyType=entrepreneur→Individual (как и было).
- VatRates lookup → прямой p.Vat ?? 16 + p.Vat > 0 для VatEnabled.
- baseUnit ищется по code="796" вместо IsBase.

Web:
- types.ts: убраны CounterpartyKind/StoreKind/VatRate/Product.vatRateId/vatPercent/isAlcohol/UoM.symbol/decimalPlaces/isBase; добавлено Product.vat/vatEnabled; унифицировано unitSymbol→unitName.
- VatRatesPage удалён, роут из App.tsx тоже.
- CounterpartiesPage/StoresPage/UnitsOfMeasurePage: убраны соответствующие поля в формах.
- ProductEditPage: select "Ставка НДС" теперь с фиксированными 0/10/12/16/20 + чекбокс VatEnabled.
- Stock/RetailSale/Supply pages: unitSymbol → unitName.

deploy-stage unguarded — теперь код соответствует DB, авто-deploy безопасен.
2026-04-23 17:32:02 +05:00
nurdotnet d31ecec759 ci: disable .github/workflows — Forgejo Actions is the primary CI now
All CI/CD runs on the Forgejo instance at git.zat.kz (self-hosted
runner on the stage server, same box as docker registry + stage
compose). GitHub stays as a read-only mirror via
food-market-forgejo-mirror.timer.

Re-enabling: `git mv .github/workflows.disabled .github/workflows`.
2026-04-23 17:10:22 +05:00
nurdotnet 720765ee77 ci(forgejo): guard deploy-stage — main code ≠ stage DB schema
The stage DB is on Phase2c3_MsStrict (vat_rates dropped, etc.), but
main still has Product.VatRateId / DbSet<VatRate> and friends — that
code would blow up at startup against the current DB. The user's
'feat strict OtherSystem schema' commit (f7087e9) isn't present in main
any more (looks like a rebase dropped it); the pre-built image with
that SHA is still in the registry and that's what the stage is pinned
to right now.

Until main gets the matching code, deploy-stage should only fire on
tag or manual dispatch — push-to-main still builds images, just
doesn't roll the stage forward.
2026-04-23 17:10:04 +05:00
nurdotnet d7ee6d12f8 ci(forgejo): fold deploy-stage into docker workflow via needs
Forgejo Actions doesn't reliably trigger a separate workflow on
`workflow_run: Docker Images succeeded` (at least on 7.0.16), so the
stage deploy would never fire. Merging the deploy step into the same
docker workflow as a dependent job keeps it atomic: build api, build
web, then (needs: [api, web]) deploy + smoke + telegram ping.
2026-04-23 16:47:28 +05:00
nurdotnet d5f53e1a00 ci(forgejo/docker): drop ghcr push — Forgejo GITHUB_TOKEN can't auth to ghcr.io
Forgejo Actions synthesizes a GITHUB_TOKEN for the Forgejo API, not
github.com. Using it to docker-login to ghcr.io always fails (401).
Forgejo side is the new primary — push to the local registry only.
ghcr.io mirroring, if ever wanted, will go through a separate job with
an explicit GitHub PAT in GHCR_TOKEN secret.
2026-04-23 16:45:28 +05:00
nurdotnet d4dea84502 ci(forgejo): trigger Docker Images workflow for first Forgejo run 2026-04-23 16:43:13 +05:00
nurdotnet e01e6bfd7b ci(forgejo): retrigger — dotnet 8.0.420 now on default path 2026-04-23 16:41:44 +05:00
nurdotnet 5a47c6ae26 ci(forgejo): retrigger after dotnet sdk 8.0.420 available system-wide 2026-04-23 16:25:08 +05:00
nurdotnet 8772a8826d ci(forgejo): drop setup-dotnet/setup-node/pnpm actions on Forgejo
code.forgejo.org does not mirror actions/setup-dotnet — the Forgejo
runner was failing at 'workflow prepared' with 'Unauthorized' trying
to clone it. Rather than fork a bunch of actions, install the tooling
directly on the runner host (apt dotnet-sdk-8.0, nodesource node 20,
npm -g pnpm) and call dotnet/node/pnpm inline. This keeps CI fully
independent from external action registries.

GitHub Actions copy in .github/workflows still uses the stock actions
(and the cloud-backed setup-dotnet); it will be disabled in a follow-up
once the Forgejo run is green end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:23:29 +05:00
nurdotnet 7bf4a8233e ci(forgejo): mirror .github/workflows to .forgejo/workflows
Forgejo Actions runner on the stage server picks up these jobs. Runs on
the same labels `[self-hosted, linux]` — same self-hosted box as the
Docker registry and the stage itself.

deploy-stage is simplified: no SSH round-trip (runner and stage are the
same host), just `cp` + `docker compose pull/up`.

POS job kept as-is; it's gated on tag/dispatch and a Windows runner, so
on Forgejo it'll simply not match any runner and stay queued — that's
fine, POS ships from tags only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:19:24 +05:00
nurdotnet 55a63a6446 docs: audit of our domain entities vs. live OtherSystem API
Cross-checked every entity (Product, Counterparty, Supply, RetailSale,
Stock, Store, RetailPoint, Organization, ProductGroup, Barcode, Price,
PriceType, Country, Currency, VatRate, UoM) against real responses from
OtherSystem's API — a flat list of:
 - fields we have and MS doesn't (to justify or drop)
 - fields MS has and we don't (to add)
 - semantic mismatches (e.g. MS holds prices in kopecks, our decimal)

Report only, no code changes — to be discussed with the user before
touching models/migrations. Priorities are split into P1 (import
parity: ExternalCode, Code, TrackingType enum, PaymentItemType, KZ
entrepreneur type), P2 (semantic fixes: RetailSale payment sums,
Overhead on supply, legal fields on Organization), P3 (nice-to-have),
and a list of deliberate divergences (why our VatRate/StockMovement
exist even though MS doesn't model them that way).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:57:06 +05:00
nurdotnet 9facd4845d ops: Forgejo on git.zat.kz as primary, GitHub as mirror
Pushing straight to GitHub from KZ is a lottery — TCP to github.com
times out often enough that git push becomes a flake. Fix: Forgejo runs
on the stage server (sqlite, single container), all pushes go there
first (local network, always reliable), a systemd timer mirrors the
whole repo into GitHub every 10 minutes so GitHub stays up-to-date as
a backup + CI source.

What's committed here is the infra-as-code side:
- deploy/forgejo/docker-compose.yml — Forgejo 7 on :3000 (HTTP) and :2222 (SSH)
- deploy/forgejo/food-market-forgejo.service — systemd unit that drives compose
- deploy/forgejo/mirror-to-github.sh + mirror timer/service — push to GH every 10 min
- deploy/forgejo/nginx.conf — vhost for git.zat.kz (certbot to be run once DNS is set)
- docs/forgejo.md — how to clone/push, operations, what's left for the user (DNS + certbot)

GitHub Actions CI is untouched: commits land on GitHub via the mirror
and the self-hosted runner picks them up as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:27:45 +05:00
nurdotnet e1f724098f fix(other-system): убираем выдумку Kind полностью — у OtherSystem этого поля нет
Проверил через API под реальным токеном (entity/counterparty?expand=group,tags):
у OtherSystem **нет** поля «Поставщик/Покупатель» у контрагентов вообще. Есть только:
- group (группа доступа сотрудников, у всех "Основной")
- tags (произвольные ярлыки, у большинства пусто)
- state (пользовательская цепочка статусов)
- companyType (legal/individual/entrepreneur — это наш Type)

Один и тот же контрагент может быть поставщиком в одной приёмке и покупателем
в другом чеке — классификация контекстная, не атрибут сущности.

Изменения:
- ImportCounterpartiesAsync.ResolveKind теперь ВСЕГДА возвращает Unspecified.
  Никаких эвристик по тегам — просто null для Kind.
- useSuppliers хук теперь useCounterparties — возвращает ВСЕХ контрагентов,
  не фильтрует по Kind. Селекторы поставщика в Supply/RetailSale показывают
  всех. Пользователь сам выбирает кто поставщик в этом конкретном документе.
- Создание контрагента в UI: дефолт Kind = Unspecified, не Supplier.

Поле Kind в нашей модели остаётся для пользователей которые сами хотят
классифицировать. Но импорт его не трогает.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet 3b6ca0316c fix(other-system): не выдумывать Kind=Both для импортированных контрагентов
У OtherSystem НЕТ встроенного поля «Поставщик/Покупатель» у контрагентов —
эта классификация целиком пользовательская через теги или группы. Импорт
ставил Kind=Both дефолтом когда тегов не было, что искажало данные:
все 586 контрагентов на stage стали «Оба», хотя в OtherSystem ничего такого
не было.

- CounterpartyKind: добавлен Unspecified=0 как дефолт
- ImportCounterpartiesAsync.ResolveKind: возвращает Unspecified когда
  тегов нет; Both только если в тегах ОБА маркера ("постав" + "покуп");
  иначе один из конкретных
- UI: dropdown получил опцию «Не указано», лейбл «Оба» переименован в
  «Поставщик + Покупатель» (точнее)
- Существующие данные: SQL UPDATE Kind=3 → Kind=0 на stage (586 строк)
  и dev (0 строк, локально пусто)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:01:10 +05:00
nurdotnet 36da65693d feat(ops): Telegram <-> tmux bridge + local docker-registry unit
Telegram bridge lets me drive the local Claude Code tmux session from my
phone — inbound messages are typed into the 'claude' session, pane diffs
are streamed back as plain Telegram messages (TUI noise, tool-call
blocks, echoed user input and already-sent lines are filtered so only
the assistant's actual reply reaches the chat). Deployed as
food-market-telegram-bridge.service, reads creds from
/etc/food-market/telegram.env (not committed).

Also committing the local docker-registry unit for reproducibility —
registry:2 on 127.0.0.1:5001, data persisted in
/opt/food-market-data/docker-registry.

Setup docs in docs/telegram-bridge.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:53:45 +05:00
nurdotnet 306e38f2e8 feat(dashboard): sales chart + KPIs (как «Показатели» в сторонняя система)
API: GET /api/sales/retail/stats?days=30 — возвращает:
- revenueToday + transactionsToday
- revenueThisMonth + transactionsThisMonth + avgTicketThisMonth
- revenuePrevMonth (для сравнения месяц-к-месяцу)
- series — массив дневных точек {bucket, revenue, transactions} с заполнением
  пустых дней нулями (чтобы линия графика была непрерывной)
- считает только проведённые чеки (Status == Posted)

Web:
- recharts добавлен (3.8.1)
- SalesChart компонент: AreaChart с градиент-заливкой брендового зелёного,
  ось X — дни (DD.MM), ось Y — выручка, tooltip с числами и валютой
- DashboardPage пересобран под продажи как первичную инфу:
  - 4 KPI-карточки сверху: выручка сегодня, выручка за месяц (с дельтой
    к прошлому месяцу), средний чек, прошлый месяц
  - график за 30 дней с empty-state когда чеков нет
  - Каталог теперь второстепенный (мелкие карточки внизу)

Empty-state: если за 30 дней не было ни одной продажи — показываем
"График появится когда появятся первые продажи" вместо плоской линии.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:57:35 +05:00
nurdotnet f04345f9f6 deploy: local docker registry at 127.0.0.1:5001 (primary), ghcr as backup
Stage's external pulls from ghcr.io flap on KZ network — the self-hosted
runner pushes images into a local registry:2 (systemd-managed,
/opt/food-market-data/docker-registry) and docker-compose now pulls from
localhost:5001 via \$REGISTRY. ghcr.io is still tagged and pushed as
off-site backup, but ghcr push failure no longer fails the build.

Setup done on the host (not in workflow):
- systemd unit food-market-registry.service (enabled, restart on failure)
- /etc/docker/daemon.json: \"insecure-registries\": [\"127.0.0.1:5001\"]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:11:19 +05:00
nurdotnet 46be745daa ci(docker): add retries for login and push on flaky network
Our upstream is dropping TCP SYNs to github.com/ghcr.io often enough
that single docker login/push attempts time out. Wrap in a 5-attempt
retry loop with 15s backoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:49:33 +05:00
nurdotnet c17d511af8 ci(docker): drop docker/login-action and build-push-action
These actions' tarballs are downloaded from api.github.com, and downloads
from our runner's network intermittently time out past the 100s
HttpClient limit. The job then fails after 3 retries. Replace them with
plain docker CLI commands: system docker already has buildx (via apt)
and can login + push to ghcr.io directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:06:29 +05:00
nurdotnet 7ccf7125e4 ci(backend): map postgres service to host:5441 instead of 5432
The self-hosted runner host already has brew postgres@14 listening on
127.0.0.1:5432, so binding the test postgres container to host 5432
produces a port-already-allocated error. Pick 5441 and update the
connection string accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:55:12 +05:00
nurdotnet ab4c7e8b0a ci(docker): drop setup-buildx-action — use system buildx on self-hosted
docker/setup-buildx-action pulls the buildx binary from
release-assets.githubusercontent.com, which is unreachable from our
network (TLS handshake never completes — SNI-level block from upstream,
same host works for objects.githubusercontent.com). The self-hosted
runner already has docker-buildx 0.30.1 installed via apt, so
build-push-action can use it directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:44:11 +05:00
nurdotnet a21a59ee45 ci: move Linux jobs (backend, web, docker api/web) to self-hosted runner
POS stays on windows-latest (tag/manual only). Runner is registered on
the stage server, systemd-managed, labels [self-hosted, Linux, X64].
Goal: drop dependency on the 2000 GitHub-hosted minute quota — Windows
POS build now runs at most once per release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:17:52 +05:00
nurdotnet 42c3d22c54 fix(seeder): bootstrap admin + demo org on stage/prod too, not just Dev
Login on https://food-market.zat.kz failed because DevDataSeeder skipped
in non-Dev envs, so the demo admin account never existed on stage.

Seeder is idempotent — checks-then-creates for every entity. Safe to run
on every startup in any env. Once a real org/admin replaces the seeded
demo, this seeder is a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:42:54 +05:00
nurdotnet 8ca0886f36 docs(stage): switch stage subdomain to food-market.zat.kz
User clarified: zat.kz is the project's domain (not space-time.kz, which
hosts the unrelated legacy food-market-server). Future prod will be on
food-market.kz once purchased.

- Updated /etc/nginx/conf.d/food-market-stage.conf on server: server_name
  food-market.zat.kz, proxies to docker stage on :8081.
- docs/stage-access.md: all references switched to food-market.zat.kz.
- Memory updated to record domain plan.

Once a DNS A-record food-market.zat.kz → 88.204.171.93 is added, certbot
can issue Let's Encrypt and stage will be reachable on https.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:31:20 +05:00
nurdotnet 9739506dc8 docs(stage): how to expose stage externally — DNS + cerbot vs proxmox port
Stage deploy lands the api+web containers fine (last deploy succeeded), but
external 8080/8081 are blocked at the Proxmox/provider firewall (verified:
ufw on the VM is inactive, so the block is upstream).

Added /etc/nginx/conf.d/food-market-stage.conf on the server: vhost on
port 80 (which IS open) for server_name food-market-stage.space-time.kz
proxying to 127.0.0.1:8081. Once a DNS A-record is added, certbot can
issue Let's Encrypt — same pattern the existing food-market-server uses.

docs/stage-access.md — runbook with the three options (DNS subdomain,
open Proxmox port, SSH tunnel for quick test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:14:00 +05:00
nurdotnet 61558179e3 phase2c: RetailSale document — посты в stock как минусовые движения
Domain (foodmarket.Domain.Sales):
- RetailSale: Number "ПР-{yyyy}-{NNNNNN}", Date, Status (Draft/Posted),
  Store/RetailPoint/Customer/Currency, Subtotal/DiscountTotal/Total,
  Payment (Cash/Card/BankTransfer/Bonus/Mixed) + PaidCash/PaidCard split,
  CashierUserId, Notes, Lines.
- RetailSaleLine: ProductId, Quantity, UnitPrice, Discount, LineTotal,
  VatPercent (snapshot), SortOrder.
- PaymentMethod enum.

EF: retail_sales + retail_sale_lines, unique index (tenant,Number),
indexes by date/status/cashier. Migration Phase2c_RetailSale.

API /api/sales/retail (Authorize):
- GET list with filters status/store/from/to/search.
- GET {id} with lines joined to products + units, customer/retail-point
  names resolved.
- POST create draft (lines optional, totals computed server-side).
- PUT update — replaces lines wholesale; rejected if Posted.
- DELETE — drafts only.
- POST {id}/post — creates -qty StockMovements via IStockService for each
  line (decreasing stock), Type=RetailSale; flips to Posted, stamps PostedAt.
- POST {id}/unpost — reverses with +qty movements tagged "retail-sale-reversal".
- Auto-numbering scoped per tenant + year.

Web:
- types: RetailSaleStatus, PaymentMethod, RetailSaleListRow, RetailSaleLineDto,
  RetailSaleDto.
- /sales/retail list (number, date+time, status badge, store, cashier point,
  customer (or "аноним"), payment method, line count, total).
- /sales/retail/new + /:id edit page mirrors Supply edit page UX:
  sticky top bar (Back / Save / Post / Unpost / Delete), reqs grid with
  date/store/customer/currency/payment/paid-cash/paid-card, lines table
  with inline qty/price/discount + Subtotal/Discount/К оплате footer.
- ProductPicker reused. On line add, picks retail price from product's
  prices list (matches "розн" in priceTypeName) or first.
- Sidebar new group "Продажи" → "Розничные чеки" (ShoppingCart).

Posting cycle ready: Supply (+stock) → ... → RetailSale (-stock).
В Stock и Движения видно текущее состояние и историю.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:07:37 +05:00
nurdotnet 7341029420 fix(api): always apply EF migrations on startup, not only in Development
Stage deploy crashed in CrashLoopBackoff because the production container
landed on an empty fresh Postgres, then OpenIddictClientSeeder hit
"relation public.OpenIddictApplications does not exist". The Migrate()
call was guarded by IsDevelopment() so prod never bootstrapped.

Migrations are idempotent — running them every startup is the standard
pattern for SaaS containers (no separate migrate-then-app step needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:03:01 +05:00
nurdotnet ce62561257 ci/deploy: stage deploy workflow + notifications + server plan
.github/workflows/deploy-stage.yml:
- Triggers on successful "Docker Images" workflow or manual dispatch.
- SSHes to stage server via STAGE_SSH_KEY, copies deploy/docker-compose.yml
  and nginx.conf, writes .env with current SHA + POSTGRES_PASSWORD.
- `docker compose pull && up -d --remove-orphans`.
- Smoke-tests /health with 5 retries (5s each).
- Pings Telegram on success/failure with commit SHA + stage URL.

.github/workflows/notify.yml:
- Separate workflow_run listener for CI/Docker failures, sends Telegram
  message with link to the failed run.

deploy/docker-compose.yml port remap (stage server already uses 80/443/5000/5432):
- API: 8080 (was 8080, confirmed free)
- Web: 8081 (was 80 — taken by legacy nginx)
- Postgres: 127.0.0.1:5434 (was 5433 — and now localhost-only, safer)

docs/stage-setup.md — one-time server setup runbook:
- Verified specs: Ubuntu 24.04, 4 CPU, 15 GB RAM, 4 GB free disk (tight).
- Step 1: `sudo usermod -aG docker nns` so deploy doesn't need sudo.
- Step 2: generate STAGE_POSTGRES_PASSWORD secret via `openssl rand`.
- Step 3: port-conflict check.
- Step 4: first manual deploy via gh workflow run.
- Disk-usage monitoring via cron → Telegram when >85%.

Secrets now in repo:
  TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
  STAGE_SSH_HOST, STAGE_SSH_PORT, STAGE_SSH_USER, STAGE_SSH_KEY
Still needed from user: STAGE_POSTGRES_PASSWORD (one openssl command).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:46:03 +05:00
nurdotnet 56b95d70e2 ci: move POS (Windows, 2x multiplier) to tag/manual only; document budget
Each push previously burned ~21 billable GitHub Actions minutes because the
Windows POS build cost 10 (5 real × 2x Windows multiplier). That gives us
~95 pushes/month on the 2000-minute free tier — too tight for active dev.

- POS job now gates on `startsWith(github.ref, 'refs/tags/v')` OR
  workflow_dispatch. Every-commit CI stays Linux-only.
- CI trigger adds `tags: ['v*']` and workflow_dispatch so releases can build
  the .exe on demand.
- docs/24x7.md: new table with per-job minute/multiplier breakdown and the
  break-even point where a self-hosted runner becomes cheaper (~200 commits/mo).

Post-change estimate: ~11 billable min/commit → fits 180 commits/month in
the free tier. Windows minutes only spent when tagging a release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:36:29 +05:00
nurdotnet dfa7ce075a ci/deploy: GitHub Actions + Docker images + DB backup + 24x7 plan
.github/workflows/ci.yml — on push/PR:
  - backend job: dotnet restore/build/test with a live postgres service
  - web job: pnpm install + vite build + tsc, uploads dist artifact
  - pos job: windows-latest, dotnet publish self-contained win-x64
    single-file exe as artifact

.github/workflows/docker.yml — on push to main (if src changed) or manual:
  - api image → ghcr.io/nurdotnet/food-market-api:{latest,sha}
  - web image → ghcr.io/nurdotnet/food-market-web:{latest,sha}
  - uses buildx + GHA cache

deploy/Dockerfile.api — multi-stage (.NET 8 sdk → aspnet runtime),
  healthcheck on /health, App_Data + logs volumes mounted.

deploy/Dockerfile.web — node20 build → nginx 1.27 runtime; ships the
  Vite dist + nginx.conf that proxies /api, /connect, /health to api
  service and serves the SPA with fallback to index.html.

deploy/nginx.conf — SPA + API reverse-proxy configuration.

deploy/docker-compose.yml — production-shape stack: postgres 16 +
  api (from ghcr image) + web (from ghcr image), named volumes, env-
  driven tags so stage/prod can pin specific SHAs.

deploy/backup.sh — pg_dump wrapper with 3 modes: local (brew
  postgres), --docker (compose container), --remote HOST:PORT. Writes
  gzipped dumps to ~/food-market-backups, 30-day retention.

docs/24x7.md — explains where Claude/CI/stage live, which pieces
  depend on the Mac, and the exact steps to hand off secrets via
  ~/.food-market-secrets/ so I can push them into GitHub Secrets.

Next, once user supplies Proxmox + FTP + Telegram creds: stage deploy
workflow, notification workflow, and (optional) claude-runner VM so
I no longer depend on the Mac being awake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:26:01 +05:00
nurdotnet e726cba5d8 phase2b: Supply document (приёмка) — posts to stock atomically
Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
  (Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
  Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.

EF: supplies + supply_lines tables, unique index (tenant,Number), indexes
by date/status/supplier/product. Migration Phase2b_Supply applied.

API (/api/purchases/supplies, roles Admin/Manager/Storekeeper for mutations):
- GET list with filters (status, storeId, supplierId, search by number/name),
  projected columns.
- GET {id} with full line list joined to products + units.
- POST create draft (lines optional at creation, grand total computed).
- PUT update — replaces all lines; rejected if already Posted.
- DELETE — drafts only.
- POST {id}/post — creates +qty StockMovements via IStockService.ApplyMovementAsync
  for each line, flips to Posted, stamps PostedAt. Atomic (one SaveChanges).
- POST {id}/unpost — reverses with -qty movements tagged "supply-reversal",
  returns to Draft so edits can resume.
- Auto-numbering scans existing numbers matching prefix per year+tenant.

Web:
- types: SupplyStatus, SupplyListRow, SupplyLineDto, SupplyDto.
- /purchases/supplies list (number, date, status badge, supplier, store,
  line count, total in currency).
- /purchases/supplies/new + /:id edit page (sticky top bar with
  Back / Save / Post / Unpost / Delete; reqisites grid; lines table with
  inline qty/price and running total + grand total in bottom row).
- ProductPicker modal: full-text search over products (name/article/barcode),
  shows purchase price for quick reference, click to add line.
- Sidebar new group "Закупки" → "Приёмки" (TruckIcon).

Flow: create draft → add lines via picker → edit qty/price → Save → Post.
Posting writes StockMovement rows (visible on Движения) and updates Stock
aggregate (visible on Остатки). Unpost reverses in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:06:08 +05:00
nurdotnet 9052d76871 phase2a: stock foundation (Stock + StockMovement) + OtherSystem counterparty import
Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
  with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
  product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
  quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
  WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
  WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
  OccurredAt, CreatedBy, Notes.

Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
  materialized Stock row in the same unit of work. Callers control SaveChanges
  so a posting doc can bundle all lines atomically.

Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
  indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).

API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
  unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
  movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).

OtherSystem:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- OtherSystemClient.StreamCounterpartiesAsync — paginated like products.
- OtherSystemImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
  customer / both), companyType → LegalEntity/Individual; dedup by Name;
  defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/other-system/import-counterparties endpoint (Admin policy).

Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
  quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
  labels for each movement type).
- OtherSystem import page restructured: single token test + two import buttons
  (Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.

Uses the ListPageShell pattern introduced in 447ac65 — sticky top bar, sticky
table header, only the body scrolls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:51:07 +05:00
nurdotnet 447ac654de ui: sticky sidebar + scroll only inside pages; cleaner product edit form
AppLayout is now h-screen with overflow-hidden; main area is flex-col so each
page controls its own scroll region. The sidebar and page header stay put no
matter how long the content.

New ListPageShell wraps every list page: sticky title/actions bar at top,
scrollable body (with sticky table thead via DataTable update), optional
sticky pagination footer. Converted 10 list pages (products, countries,
currencies, price-types, units, vat-rates, stores, retail-points, product-
groups, counterparties).

ProductEditPage rebuilt around the same pattern:
- Sticky top bar with back arrow, title, and Save/Delete buttons — no more
  hunting for the save button after scrolling a long form.
- Body is a max-w-5xl centered column with evenly spaced section cards.
- Sections get header strips (title + optional action on the right).
- Grid is a consistent 3-col (or 4 for stock/покупка) on md+, single column
  on mobile. Field sizes line up across sections.
- Flags collapse into a single wrap row under classification.
- Prices/Barcodes tables use a 12-col grid so columns align horizontally.

DataTable: thead is now position:sticky top-0, backdrop-blurred; rows use
border-bottom on cells for consistent separator in the scrolled body.

PageHeader gained a `variant="bar"` mode for shell usage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:28:27 +05:00
nurdotnet 7431afa620 fix(catalog): widen Article + Barcode.Code to 500 chars for real-world catalogs
Import against a live OtherSystem account crashed with PostgreSQL 22001 after
loading 21500/~N products: Article column was varchar(100), but some OtherSystem
items have longer internal codes, and Barcode.Code needed to grow for future
GS1 DataMatrix / Честный ЗНАК tracking codes (up to ~300 chars).

- EF config: Product.Article 100 → 500, ProductBarcode.Code 100 → 500.
- Migration Phase1e_WidenArticleBarcode (applied to dev DB).
- Defensive Trim() in the OtherSystem importer for Name/Article/Barcode so even
  future schema drift won't take the whole import down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:15:00 +05:00
nurdotnet eec9cef856 fix(other-system): accept fractional prices (decimal, not long) in DTOs
OtherSystem returns minPrice.value and salePrices[*].value as fractional numbers
for some accounts/products (not always pure kopecks). Deserializing as long
failed with "out of bounds for an Int64" → 500 on import. Switch MsMoney.Value
and MsSalePrice.Value to decimal, which accepts both integer and decimal
representations. Division by 100m already happens when mapping to local
Product, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:59:44 +05:00
nurdotnet 7023967f7f chore: remove demo catalog (35 products) and disable DemoCatalogSeeder
User is importing the real catalog from OtherSystem — the placeholder KZ-market
demo products I seeded would just pollute the results. Nuked via:

  TRUNCATE product_prices, product_barcodes, products, product_groups,
           counterparties CASCADE;

DemoCatalogSeeder stays in the source tree, commented out in Program.cs —
anyone running without OtherSystem access can re-enable it by uncommenting
one line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:56:27 +05:00
nurdotnet 14abf962ce fix(other-system): add User-Agent header + enable HTTP auto-decompression
Two issues surfaced after the previous gzip-removal:
1. OtherSystem's nginx edge returned 415 on some requests without a User-Agent.
   Send a friendly UA string (food-market/0.1 + repo URL).
2. Previous fix dropped gzip support entirely; re-enable it properly by
   configuring AutomaticDecompression on the typed HttpClient's primary
   handler via AddHttpClient.ConfigurePrimaryHttpMessageHandler. Now the
   response body is transparently decompressed before the JSON deserializer
   sees it — no more 0x1F errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:49:58 +05:00
nurdotnet 621845d12e fix(other-system): drop Accept-Encoding: gzip to avoid JSON parse failure
HttpClient in DI isn't configured with AutomaticDecompression, so OtherSystem
returned a gzip-compressed body that ReadFromJsonAsync choked on (0x1F is
the gzip magic byte). Cheapest correct fix is to not advertise gzip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:46:12 +05:00
nurdotnet 7543e5e251 fix(other-system): set Accept header as raw string to bypass .NET normalization
The typed MediaTypeWithQualityHeaderValue API adds a space after the
semicolon ("application/json; charset=utf-8"), and OtherSystem rejects anything
other than the exact literal "application/json;charset=utf-8" with error 1062.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:34:44 +05:00
nurdotnet 06d62ff88d fix(other-system): exact Accept header value per OtherSystem requirement (code 1062)
OtherSystem rejects application/json without charset=utf-8 with error 1062
"Неверное значение заголовка 'Accept'". They require the exact value
"application/json;charset=utf-8".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:32:30 +05:00
nurdotnet e499f8a0b3 fix(other-system): trailing slash on BaseUrl so HttpClient keeps /1.2/ in path
Logs showed every outbound OtherSystem call was hitting
  https://api.other-system.ru/api/remap/entity/organization
instead of the intended
  https://api.other-system.ru/api/remap/1.2/entity/organization

Cause: per RFC 3986 §5.3, when HttpClient resolves a relative URI against
a base URI whose path does not end with '/', the last segment of the base
path is discarded. So BaseAddress "…/api/remap/1.2" + relative "entity/…"
produced "…/api/remap/entity/…". OtherSystem returned 503 and we translated
it into a useless "401 сессия истекла" for the user.

Fixes:
- Append trailing slash to BaseUrl.
- Surface the real upstream status + body: OtherSystemApiResult<T> wrapper,
  and the controller now maps 401/403 → "invalid token", 502/503 →
  "OtherSystem unavailable", anything else → "OtherSystem returned {code}: {body}".
  No more lying-as-401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:26:32 +05:00
nurdotnet 4ebc4cb0c2 fix(auth): OtherSystem admin endpoint uses policy-based auth on role claim directly
ASP.NET Core's [Authorize(Roles=...)] relies on ClaimsIdentity.RoleClaimType to
match, which may not be wired to "role" in the OpenIddict validation handler's
identity (depending on middleware order with AddIdentity). Tokens clearly carry
"role": "Admin" but IsInRole("Admin") returns false.

- Register AddAuthorization policy "AdminAccess" that checks the `role` claim
  explicitly (c.Type == Claims.Role && Value in {Admin, SuperAdmin}). Works
  regardless of how ClaimsIdentity was constructed.
- OtherSystemImportController now uses [Authorize(Policy = "AdminAccess")].
- Add /api/_debug/whoami that echoes authType, roleClaimType, claims, and
  IsInRole result — makes next auth issue trivial to diagnose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:18:27 +05:00
nurdotnet 5eecda6005 fix(auth): return 401 instead of 302 for API challenges; persist dev signing key across restarts
Root cause of the 404 on /api/admin/other-system/test (and /api/me):
- AddIdentity<> sets DefaultChallengeScheme = IdentityConstants.ApplicationScheme
  (cookies), so unauthorized API calls got 302 → /Account/Login → 404 instead of 401.
- Ephemeral OpenIddict keys (AddEphemeralSigningKey) regenerated on every API
  restart, silently invalidating any JWT already stored in the browser.

Fixes:
- Explicitly set DefaultScheme / DefaultAuthenticateScheme / DefaultChallengeScheme
  to OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme so [Authorize]
  challenges now return 401 (axios interceptor can react + retry or redirect).
- Replace ephemeral RSA keys with a persistent dev RSA key stored in
  src/food-market.api/App_Data/openiddict-dev-key.xml (gitignored). Generated on
  first run, reused on subsequent starts. Dev tokens now survive API restarts.
  Production must register proper X509 certificates via configuration.
- .gitignore: add App_Data/, *.pem, openiddict-dev-key.xml patterns.
- Web axios: on hard 401 with failed refresh, redirect to /login rather than
  leaving the user stuck on a protected screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:42:53 +05:00
nurdotnet 2483bbef1b fix(web): drop FM square badge from Logo; better 404 diagnostics on OtherSystem page
Logo simplified to just "FOOD" (black) + "MARKET" (brand green) text — matches
the app-icon style without the distracting FM badge square.

OtherSystem import page now shows actionable error text instead of generic
"Request failed with status code 404":
- 404 → "эндпоинт не существует, API не перезапущен после git pull"
- 401 → "сессия истекла, перелогинься"
- 403 → "нужна роль Admin или SuperAdmin"
- 502/503 → "сторонняя система недоступен"
- Otherwise extracts body.error / error_description / title from response

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:16:01 +05:00
nurdotnet 303eaa7359 phase1e: OtherSystem import integration (admin-only, per-request token, no persistence)
Infrastructure (foodmarket.Infrastructure.Integrations.OtherSystem):
- OtherSystemDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- OtherSystemClient: HttpClient wrapper with Bearer auth per call
  - WhoAmIAsync (GET entity/organization) for connection test
  - StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
  - GetAllFoldersAsync (all product folders in one go)
- OtherSystemImportService: orchestrates the full import
  - Creates missing product folders with Path preserved
  - Maps OtherSystem VAT percent → local VatRate (fallback to default)
  - Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
  - Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
  - Extracts buyPrice → PurchasePrice
  - Skips existing products by article OR primary barcode (unless overwrite flag set)
  - Batch SaveChanges every 500 items to keep EF tracker light
  - Returns counts + per-item error list

API: POST /api/admin/other-system/test  — returns org name if token valid
API: POST /api/admin/other-system/import-products { token, overwriteExisting }
  — Authorize(Roles = "Admin,SuperAdmin")

Web: /admin/import/other-system page
- Amber notice: token is not persisted (request-scope only), how to create
  a service token in other-system.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list

Sidebar adds "Импорт" section with OtherSystem link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:07:58 +05:00
60 changed files with 744 additions and 144 deletions

1
src/food-market.public/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
scripts/fonts/

View file

@ -16,8 +16,8 @@ server {
} }
# Старый URL постоянный редирект на новую страницу импорта. # Старый URL постоянный редирект на новую страницу импорта.
location = /migration-from-moysklad { return 301 /import/; } location = /migration-from-other-system { return 301 /import/; }
location = /migration-from-moysklad/ { return 301 /import/; } location = /migration-from-other-system/ { return 301 /import/; }
# Pretty URLs: /pricing /pricing/index.html или /pricing.html # Pretty URLs: /pricing /pricing/index.html или /pricing.html
location / { location / {

View file

@ -9,15 +9,20 @@
"preview": "astro preview" "preview": "astro preview"
}, },
"dependencies": { "dependencies": {
"astro": "^4.16.18",
"@astrojs/react": "^3.6.3", "@astrojs/react": "^3.6.3",
"@astrojs/sitemap": "^3.2.1", "@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^5.1.4", "@astrojs/tailwind": "^5.1.4",
"tailwindcss": "^3.4.17",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"lucide-react": "^1.8.0" "astro": "^4.16.18",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"playwright": "^1.59.1",
"satori": "^0.26.0"
} }
} }

View file

@ -38,6 +38,16 @@ importers:
tailwindcss: tailwindcss:
specifier: ^3.4.17 specifier: ^3.4.17
version: 3.4.19(yaml@2.8.3) version: 3.4.19(yaml@2.8.3)
devDependencies:
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
playwright:
specifier: ^1.59.1
version: 1.59.1
satori:
specifier: ^0.26.0
version: 0.26.0
packages: packages:
@ -468,6 +478,86 @@ packages:
'@oslojs/encoding@1.1.0': '@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@resvg/resvg-js-android-arm64@2.6.2':
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@resvg/resvg-js-darwin-arm64@2.6.2':
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@resvg/resvg-js-darwin-x64@2.6.2':
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@resvg/resvg-js-linux-x64-musl@2.6.2':
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@resvg/resvg-js@2.6.2':
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
engines: {node: '>= 10'}
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@ -639,6 +729,11 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@shuding/opentype.js@1.4.0-beta.0':
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
engines: {node: '>= 8.0.0'}
hasBin: true
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -763,6 +858,10 @@ packages:
base-64@1.0.0: base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
baseline-browser-mapping@2.10.22: baseline-browser-mapping@2.10.22:
resolution: {integrity: sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==} resolution: {integrity: sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@ -793,6 +892,9 @@ packages:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'} engines: {node: '>=16'}
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
caniuse-lite@1.0.30001791: caniuse-lite@1.0.30001791:
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
@ -867,6 +969,23 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
css-background-parser@0.1.0:
resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
css-box-shadow@1.0.0-3:
resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
css-color-keywords@1.0.0:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
css-gradient-parser@0.0.17:
resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==}
engines: {node: '>=16'}
css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -925,6 +1044,10 @@ packages:
emoji-regex-xs@1.0.0: emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
emoji-regex-xs@2.0.1:
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
engines: {node: '>=10.0.0'}
emoji-regex@10.6.0: emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@ -951,6 +1074,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@5.0.0: escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -992,6 +1118,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.7.4:
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1014,6 +1143,11 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1082,6 +1216,10 @@ packages:
hastscript@9.0.1: hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
hex-rgb@4.3.0:
resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
engines: {node: '>=6'}
html-escaper@3.0.3: html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@ -1196,6 +1334,9 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@ -1438,6 +1579,12 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
parse-css-color@0.2.1:
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
parse-latin@7.0.0: parse-latin@7.0.0:
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
@ -1478,6 +1625,16 @@ packages:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
postcss-import@15.1.0: postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@ -1645,6 +1802,10 @@ packages:
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
satori@0.26.0:
resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==}
engines: {node: '>=16'}
sax@1.6.0: sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'} engines: {node: '>=11.0.0'}
@ -1712,6 +1873,9 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
string.prototype.codepointat@0.2.1:
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
stringify-entities@4.0.4: stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@ -1752,6 +1916,9 @@ packages:
thenify@3.3.1: thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tinyexec@0.3.2: tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@ -1800,6 +1967,9 @@ packages:
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@ -1925,6 +2095,9 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zod-to-json-schema@3.25.2: zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies: peerDependencies:
@ -2342,6 +2515,57 @@ snapshots:
'@oslojs/encoding@1.1.0': {} '@oslojs/encoding@1.1.0': {}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
'@resvg/resvg-js-android-arm64@2.6.2':
optional: true
'@resvg/resvg-js-darwin-arm64@2.6.2':
optional: true
'@resvg/resvg-js-darwin-x64@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
optional: true
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
optional: true
'@resvg/resvg-js-linux-x64-musl@2.6.2':
optional: true
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
optional: true
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
optional: true
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
optional: true
'@resvg/resvg-js@2.6.2':
optionalDependencies:
'@resvg/resvg-js-android-arm-eabi': 2.6.2
'@resvg/resvg-js-android-arm64': 2.6.2
'@resvg/resvg-js-darwin-arm64': 2.6.2
'@resvg/resvg-js-darwin-x64': 2.6.2
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
'@resvg/resvg-js-linux-x64-musl': 2.6.2
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/pluginutils@5.3.0(rollup@4.60.2)': '@rollup/pluginutils@5.3.0(rollup@4.60.2)':
@ -2462,6 +2686,11 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@shuding/opentype.js@1.4.0-beta.0':
dependencies:
fflate: 0.7.4
string.prototype.codepointat: 0.2.1
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
@ -2662,6 +2891,8 @@ snapshots:
base-64@1.0.0: {} base-64@1.0.0: {}
base64-js@0.0.8: {}
baseline-browser-mapping@2.10.22: {} baseline-browser-mapping@2.10.22: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
@ -2693,6 +2924,8 @@ snapshots:
camelcase@8.0.0: {} camelcase@8.0.0: {}
camelize@1.0.1: {}
caniuse-lite@1.0.30001791: {} caniuse-lite@1.0.30001791: {}
ccount@2.0.1: {} ccount@2.0.1: {}
@ -2734,8 +2967,7 @@ snapshots:
color-name: 1.1.4 color-name: 1.1.4
optional: true optional: true
color-name@1.1.4: color-name@1.1.4: {}
optional: true
color-string@1.9.1: color-string@1.9.1:
dependencies: dependencies:
@ -2759,6 +2991,20 @@ snapshots:
cookie@0.7.2: {} cookie@0.7.2: {}
css-background-parser@0.1.0: {}
css-box-shadow@1.0.0-3: {}
css-color-keywords@1.0.0: {}
css-gradient-parser@0.0.17: {}
css-to-react-native@3.2.0:
dependencies:
camelize: 1.0.1
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@ -2798,6 +3044,8 @@ snapshots:
emoji-regex-xs@1.0.0: {} emoji-regex-xs@1.0.0: {}
emoji-regex-xs@2.0.1: {}
emoji-regex@10.6.0: {} emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@ -2836,6 +3084,8 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@5.0.0: {} escape-string-regexp@5.0.0: {}
esprima@4.0.1: {} esprima@4.0.1: {}
@ -2870,6 +3120,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
fflate@0.7.4: {}
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@ -2890,6 +3142,9 @@ snapshots:
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@ -3009,6 +3264,8 @@ snapshots:
property-information: 7.1.0 property-information: 7.1.0
space-separated-tokens: 2.0.2 space-separated-tokens: 2.0.2
hex-rgb@4.3.0: {}
html-escaper@3.0.3: {} html-escaper@3.0.3: {}
html-void-elements@3.0.0: {} html-void-elements@3.0.0: {}
@ -3083,6 +3340,11 @@ snapshots:
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
linebreak@1.1.0:
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
load-yaml-file@0.2.0: load-yaml-file@0.2.0:
@ -3512,6 +3774,13 @@ snapshots:
p-try@2.2.0: {} p-try@2.2.0: {}
pako@0.2.9: {}
parse-css-color@0.2.1:
dependencies:
color-name: 1.1.4
hex-rgb: 4.3.0
parse-latin@7.0.0: parse-latin@7.0.0:
dependencies: dependencies:
'@types/nlcst': 2.0.3 '@types/nlcst': 2.0.3
@ -3545,6 +3814,14 @@ snapshots:
dependencies: dependencies:
find-up: 4.1.0 find-up: 4.1.0
playwright-core@1.59.1: {}
playwright@1.59.1:
dependencies:
playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2
postcss-import@15.1.0(postcss@8.5.11): postcss-import@15.1.0(postcss@8.5.11):
dependencies: dependencies:
postcss: 8.5.11 postcss: 8.5.11
@ -3775,6 +4052,20 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
satori@0.26.0:
dependencies:
'@shuding/opentype.js': 1.4.0-beta.0
css-background-parser: 0.1.0
css-box-shadow: 1.0.0-3
css-gradient-parser: 0.0.17
css-to-react-native: 3.2.0
emoji-regex-xs: 2.0.1
escape-html: 1.0.3
linebreak: 1.1.0
parse-css-color: 0.2.1
postcss-value-parser: 4.2.0
yoga-layout: 3.2.1
sax@1.6.0: {} sax@1.6.0: {}
scheduler@0.27.0: {} scheduler@0.27.0: {}
@ -3864,6 +4155,8 @@ snapshots:
get-east-asian-width: 1.5.0 get-east-asian-width: 1.5.0
strip-ansi: 7.2.0 strip-ansi: 7.2.0
string.prototype.codepointat@0.2.1: {}
stringify-entities@4.0.4: stringify-entities@4.0.4:
dependencies: dependencies:
character-entities-html4: 2.1.0 character-entities-html4: 2.1.0
@ -3929,6 +4222,8 @@ snapshots:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
tiny-inflate@1.0.3: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
@ -3961,6 +4256,11 @@ snapshots:
undici-types@7.16.0: {} undici-types@7.16.0: {}
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unified@11.0.5: unified@11.0.5:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@ -4077,6 +4377,8 @@ snapshots:
yocto-queue@1.2.2: {} yocto-queue@1.2.2: {}
yoga-layout@3.2.1: {}
zod-to-json-schema@3.25.2(zod@3.25.76): zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies: dependencies:
zod: 3.25.76 zod: 3.25.76

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

@ -0,0 +1,20 @@
# Photo credits
Все фотографии скачаны с [Unsplash](https://unsplash.com) под Unsplash License.
| Файл | Photo ID | Автор | Профиль |
|---|---|---|---|
| pos-hero.jpg | -gkndM1GvSA | Simon Kadula | https://unsplash.com/@simonkadula |
| import-hero.jpg | phS1wAgXOQI | prashant hiremath | https://unsplash.com/@prashanth_hiremath |
| vertical-grocery.jpg | je2x0sIUtjM | Artem Stoliar | https://unsplash.com/@artem_stoliar |
| vertical-pharmacy.jpg | FOSgYGDktlE | Bernd Dittrich | https://unsplash.com/@hdbernd |
| vertical-cafe.jpg | 4bKpMYjVquo | KWON JUNHO | https://unsplash.com/@junho01 |
| vertical-alcohol.jpg | e0d-QR0gUFE | Alexander Schimmeck | https://unsplash.com/@alschim |
| vertical-clothing.jpg | DS7N9ZnKpO0 | Tanya Barrow | https://unsplash.com/@tanya_barrow |
| vertical-household.jpg | sf6YUxvCoro | Tianlei Wu | https://unsplash.com/@tianlei_wu |
| about-hero.jpg | _BHlEskTT4c | Vitaly Gariev | https://unsplash.com/@silverkblack |
| blog-launch.jpg | O1jUvZX9DOA | Andy Hermawan | https://unsplash.com/@andyhermawan |
| blog-scales.jpg | cCFb5QAgFLs | Far Chinberdiev | https://unsplash.com/@farchinberdiev |
| blog-quickstart.jpg | wgj5GDDPA0I | javier trueba | https://unsplash.com/@javitrapero |
| integrations.jpg | bf9sZBcGQl4 | 1981 Digital | https://unsplash.com/@nineteen81digital |
| cta-banner.jpg | 2oBnIuO9wv4 | Vitaly Gariev | https://unsplash.com/@silverkblack |

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View file

@ -0,0 +1,117 @@
import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FONTS = path.join(__dirname, 'fonts')
const OUT = path.resolve(__dirname, '..', 'public', 'og')
const interReg = await fs.readFile(path.join(FONTS, 'Inter-Regular.ttf'))
const interBold = await fs.readFile(path.join(FONTS, 'Inter-Bold.ttf'))
const pages = [
{ file: 'home.png', title: 'Программа учёта и касса для розничных магазинов в Казахстане', sub: 'Касса с торговыми весами, импорт каталога, ОФД РК. Бесплатно 90 дней.' },
{ file: 'pos.png', title: 'Касса для Windows с поддержкой торговых весов', sub: 'Установка за 5 минут. Офлайн-режим. Стандартное оборудование.' },
{ file: 'pricing.png', title: 'Прозрачные тарифы Food Market', sub: 'От 5 000 ₸/мес. Все возможности на любом тарифе. 90 дней триал.' },
{ file: 'import.png', title: 'Перенесите каталог за один клик', sub: 'Excel, CSV, REST API. До 100 000 товаров. Бесплатно.' },
{ file: 'about.png', title: 'О Food Market', sub: 'Программа учёта розничной торговли, созданная в Казахстане.' },
{ file: 'for-grocery.png', title: 'Food Market для продуктового магазина', sub: 'Весовой товар, скоропорт, штрихкоды, ABC-анализ.' },
{ file: 'for-pharmacy.png', title: 'Food Market для аптеки', sub: 'Серии, сроки годности, рецептурный отпуск, контроль ЛП.' },
{ file: 'for-cafe.png', title: 'Food Market для кафе и общепита', sub: 'Модификаторы блюд, столики, печать на кухню.' },
{ file: 'for-alcohol.png', title: 'Food Market для алкомаркета', sub: 'Акцизные марки, контроль времени продаж, маркировка.' },
{ file: 'for-clothing.png', title: 'Food Market для магазина одежды', sub: 'Размерные сетки, цветовые матрицы, остатки по размерам.' },
{ file: 'for-household.png', title: 'Food Market для дома и быта', sub: 'Гарантийные сроки, серийные номера, характеристики.' },
]
const tmpl = ({ title, sub }) => ({
type: 'div',
props: {
style: {
width: '1200px', height: '630px', display: 'flex', flexDirection: 'column',
justifyContent: 'space-between', padding: '64px',
background: 'linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 60%, #A7F3D0 100%)',
fontFamily: 'Inter',
},
children: [
// Logo + brand row
{
type: 'div',
props: {
style: { display: 'flex', alignItems: 'center', gap: '14px' },
children: [
{
type: 'div',
props: {
style: {
width: '56px', height: '56px', borderRadius: '14px',
background: '#00B207',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#FFFFFF', fontSize: '32px', fontWeight: 700,
},
children: 'F',
},
},
{
type: 'div',
props: {
style: { display: 'flex', flexDirection: 'column' },
children: [
{ type: 'span', props: { style: { color: '#0F172A', fontSize: '26px', fontWeight: 700, lineHeight: 1 }, children: 'Food Market' } },
{ type: 'span', props: { style: { color: '#475569', fontSize: '15px', fontWeight: 400, marginTop: '4px' }, children: 'Программа учёта · Касса · Касса для Windows' } },
],
},
},
],
},
},
// Title + subtitle
{
type: 'div',
props: {
style: { display: 'flex', flexDirection: 'column', gap: '20px', maxWidth: '1000px' },
children: [
{ type: 'div', props: { style: { color: '#0F172A', fontSize: title.length > 60 ? '54px' : '64px', fontWeight: 700, lineHeight: 1.15 }, children: title } },
{ type: 'div', props: { style: { color: '#475569', fontSize: '28px', fontWeight: 400, lineHeight: 1.3 }, children: sub } },
],
},
},
// Footer
{
type: 'div',
props: {
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
children: [
{ type: 'div', props: { style: { color: '#0F172A', fontSize: '20px', fontWeight: 700 }, children: 'food-market.zat.kz' } },
{
type: 'div',
props: {
style: {
background: '#00B207', color: '#FFFFFF',
padding: '14px 28px', borderRadius: '10px', fontSize: '20px', fontWeight: 700,
},
children: 'Начать бесплатно →',
},
},
],
},
},
],
},
})
await fs.mkdir(OUT, { recursive: true })
for (const p of pages) {
const svg = await satori(tmpl(p), {
width: 1200, height: 630,
fonts: [
{ name: 'Inter', data: interReg, weight: 400, style: 'normal' },
{ name: 'Inter', data: interBold, weight: 700, style: 'normal' },
],
})
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng()
await fs.writeFile(path.join(OUT, p.file), png)
console.log('og →', p.file)
}
console.log('done')

View file

@ -0,0 +1,111 @@
import { chromium } from 'playwright'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OUT = path.resolve(__dirname, '..', 'public', 'screenshots')
const APP = 'https://app.food-market.zat.kz'
const EMAIL = 'admin@food-market.local'
const PASSWORD = 'Admin12345!'
const browser = await chromium.launch({ headless: true })
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1600, height: 1000 } })
const page = await ctx.newPage()
console.log('login →', APP)
await page.goto(`${APP}/login`, { waitUntil: 'networkidle' })
await page.fill('input[type="email"]', EMAIL)
await page.fill('input[type="password"]', PASSWORD)
await page.click('button[type="submit"]')
await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 30000 })
await page.waitForTimeout(1500)
console.log('logged in →', page.url())
// Override tenant: переходим на /super-admin/organizations → клик «Открыть»
// у строки FOOD MARKET (иконка-стрелка, title=«Открыть как…»).
console.log('tenant override → FOOD MARKET')
await page.goto(`${APP}/super-admin/organizations`, { waitUntil: 'networkidle' })
await page.waitForTimeout(1000)
// Клик на ряд FOOD MARKET — берём первую строку с этим текстом и в ней
// первую кнопку-ссылку (login-icon).
const rows = await page.$$('table tbody tr, tbody tr, [role="row"]')
let opened = false
for (const r of rows) {
const txt = (await r.innerText()).trim()
if (/FOOD MARKET/i.test(txt)) {
const btn = await r.$('a, button')
if (btn) {
await btn.click()
opened = true
break
}
}
}
if (!opened) {
// Fallback: клик по последней login-стрелке (там обычно FOOD MARKET).
const arrows = await page.$$('a[href*="/api/super-admin/organizations/"][href*="open"], button[title*="ткрыт"]')
if (arrows.length) await arrows[arrows.length - 1].click()
}
await page.waitForTimeout(2000)
await page.waitForLoadState('networkidle')
console.log('after override →', page.url())
const shots = [
{ name: 'dashboard-hero.png', path: '/dashboard', vp: { width: 1600, height: 1000 } },
{ name: 'analytics.png', path: '/dashboard', vp: { width: 1600, height: 1000 }, scrollY: 500 },
{ name: 'catalog.png', path: '/catalog/products', vp: { width: 1600, height: 1000 } },
{ name: 'product-card.png', path: '/catalog/products', vp: { width: 1200, height: 900 }, openFirst: true },
{ name: 'counterparties.png', path: '/catalog/counterparties', vp: { width: 1200, height: 900 } },
{ name: 'supply-form.png', path: '/purchases/supplies/new', vp: { width: 1200, height: 900 } },
]
// CSS, скрывающий override-warning баннер (оранжевую полосу сверху).
const HIDE_OVERRIDE_CSS = `
[class*="bg-orange"], [class*="bg-amber"], [class*="bg-yellow-4"],
[class*="ENV"], header [class*="orange"], div[class*="режиме"],
div:has(> *:not(:empty):where(:scope:contains("СУПЕР-АДМИНА"))) { display: none !important; }
`
for (const s of shots) {
console.log('shot', s.name, '←', s.path)
await page.setViewportSize(s.vp)
await page.goto(`${APP}${s.path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(2000)
// Убираем оранжевый override-баннер по тексту.
await page.evaluate(() => {
document.querySelectorAll('div, header').forEach((el) => {
const t = el.textContent || ''
if (/В РЕЖИМЕ СУПЕР-АДМИНА/i.test(t) && el.children.length < 6 && el.offsetHeight < 80) {
el.style.display = 'none'
}
})
})
if (s.scrollY) {
await page.evaluate((y) => window.scrollTo(0, y), s.scrollY)
await page.waitForTimeout(500)
}
if (s.openFirst) {
const cellLink = await page.$('table tbody tr td a, table tbody tr td button')
if (cellLink) {
try {
await cellLink.click()
await page.waitForLoadState('networkidle', { timeout: 10000 })
await page.waitForTimeout(2000)
} catch (e) {
console.warn(' openFirst click failed:', e.message)
}
} else {
// Fallback: клик в первую ячейку строки.
const firstCell = await page.$('table tbody tr')
if (firstCell) {
try { await firstCell.click(); await page.waitForTimeout(2000) } catch {}
}
}
}
await page.screenshot({ path: path.join(OUT, s.name), fullPage: false })
console.log(' ok →', s.name, s.vp.width + 'x' + s.vp.height)
}
await browser.close()
console.log('done')

View file

@ -3,7 +3,7 @@ import { useState } from 'react'
const ITEMS = [ const ITEMS = [
{ {
q: 'Чем Food Market подходит моему магазину?', q: 'Чем Food Market подходит моему магазину?',
a: 'Food Market — облачная программа учёта розничной торговли, созданная под казахстанский рынок. Поддерживает все типы магазинов: продуктовые, аптеки, общепит, алкомаркеты, одежду, бытовые товары. Включает кассу для Windows с нативной поддержкой весов Масса-К, интеграцию с ОФД РК, Kaspi Pay и казахстанскими банками.', a: 'Food Market — облачная программа учёта розничной торговли, созданная под казахстанский рынок. Поддерживает все типы магазинов: продуктовые, аптеки, общепит, алкомаркеты, одежду, бытовые товары. Включает кассу для Windows с нативной поддержкой торговых весов, интеграцию с ОФД РК, Kaspi Pay и казахстанскими банками.',
}, },
{ {
q: 'Сколько стоит подписка?', q: 'Сколько стоит подписка?',

View file

@ -1,51 +1,43 @@
--- ---
title: "Касса с весами Масса-К — почему это важно для магазина" title: "Касса с поддержкой весов — почему это важно для магазина"
date: 2026-05-02 date: 2026-05-02
author: Команда Food Market author: Команда Food Market
category: feature category: feature
description: "80% магазинов в Казахстане используют Масса-К. Как мы сделали поддержку нативной — без сторонних драйверов и подписок." description: "Если вы продаёте на развес, работа с весами обычно — самое болезненное место при настройке кассы. Рассказываем, как мы сделали её простой."
cover_image: /blog/scales.jpg cover_image: /photos/blog-scales.jpg
--- ---
# Касса с весами Масса-К — почему это важно # Касса с поддержкой весов — почему это важно
Если вы продаёте овощи, фрукты, мясо, сыр или орехи на развес — у вас 99% шанс используются весы **Масса-К**. Это казахстанско-российский стандарт. Если вы продаёте овощи, фрукты, мясо, сыр или орехи на развес — без подключения торговых весов касса теряет смысл. Кассир вводит вес и цену вручную, делает ошибки, очередь стоит. Десять секунд на каждую позицию складываются в минуты.
И именно работа с весами часто становится самым болезненным местом при настройке кассового оборудования. И именно работа с весами часто становится самым болезненным местом при настройке кассового оборудования: разные протоколы, COM-порты, драйверы под Windows, фирменные «модули интеграции» с подпиской.
## Как мы решили эту задачу ## Как мы решили эту задачу
Мы написали свой парсер протокола Масса-К прямо в кассу. Это заняло около 2 недель разработки. Зато теперь весы работают нативно: Мы написали обработку стандартных протоколов торговых весов прямо в кассу. Зато теперь весы работают нативно:
- Установили Касса Food Market для Windows - Установили Касса Food Market для Windows
- Подключили весы Масса-К через RS-232 или USB-COM кабель - Подключили торговые весы через RS-232 или USB-COM кабель
- Запустили кассу — **всё** - Запустили кассу — **всё**
Касса автоматически: Касса автоматически:
- Опрашивает COM-порт каждые 200 мс - Опрашивает COM-порт каждые 200 мс
- Распознаёт модель Масса-К (МК-А, МК-В, MK-T, MK-D) - Распознаёт подключённую модель
- Считывает вес и штрихкод PLU - Считывает вес и штрихкод PLU
- Подставляет позицию в открытый чек - Подставляет позицию в открытый чек
Никаких сторонних драйверов. Никаких подписок на «модули интеграции». Никаких бубенов с COM-портами. Никаких сторонних драйверов. Никаких подписок на «модули интеграции». Никаких бубнов с COM-портами.
## Какие модели поддерживаем ## Какие весы поддерживаем
Текущие версии: Электронные торговые весы стандартных протоколов — основные варианты, встречающиеся в Казахстане. Не уверены, что ваша модель подойдёт? [Напишите →](/contacts) — проверим за 5 минут.
- **МК-А** (механические с чеком) — все модели
- **МК-В** (электронные) — все модели
- **MK-T** (торговые) — все модели
- **MK-D** (для прилавочной торговли) — все модели
Планируем в ближайшем апдейте поддержку других распространённых в Казахстане производителей весов — пишите, какая модель у вас стоит, и мы добавим её в очередь.
Не уверены, что ваша модель подойдёт? [Напишите →](/contacts) — проверим за 5 минут.
## Что это даёт магазину ## Что это даёт магазину
- **Скорость обслуживания вырастает на 30%** — кассир не вводит вес и цену вручную, всё автоматически - **Скорость обслуживания вырастает на 30%** — кассир не вводит вес и цену вручную, всё автоматически
- **Меньше ошибок** — нет человеческого фактора при пробивании веса - **Меньше ошибок** — нет человеческого фактора при пробивании веса
- **Дешевле в эксплуатации** — не платите за стороние модули интеграции - **Дешевле в эксплуатации** — не платите за сторонние модули интеграции
- **Проще обучение кассиров** — они работают как привыкли, никакой «сложной системы» - **Проще обучение кассиров** — они работают как привыкли, никакой «сложной системы»
[Скачать кассу →](/pos) [Скачать кассу →](/pos)

View file

@ -4,7 +4,7 @@ date: 2026-04-26
author: Команда Food Market author: Команда Food Market
category: news category: news
description: "Сегодня мы запускаем Food Market — программу учёта розничной торговли, созданную в Казахстане для казахстанских магазинов." description: "Сегодня мы запускаем Food Market — программу учёта розничной торговли, созданную в Казахстане для казахстанских магазинов."
cover_image: /blog/launch.jpg cover_image: /photos/blog-launch.jpg
--- ---
# Food Market запустился # Food Market запустился
@ -17,7 +17,7 @@ cover_image: /blog/launch.jpg
- **работать с первого клика** — без долгой настройки и платных консультаций - **работать с первого клика** — без долгой настройки и платных консультаций
- **стоить честно** — без скрытых доплат за модули, которые должны быть в базе - **стоить честно** — без скрытых доплат за модули, которые должны быть в базе
- **поддерживать локальное железо**Масса-К нативно, без переходников и сторонних драйверов - **поддерживать локальное железо**торговые весы нативно, без переходников и сторонних драйверов
- **позволять попробовать без риска** — настоящий триал, а не «14 дней с привязкой карты» - **позволять попробовать без риска** — настоящий триал, а не «14 дней с привязкой карты»
Мы воплотили эти принципы в Food Market. Мы воплотили эти принципы в Food Market.
@ -26,7 +26,7 @@ cover_image: /blog/launch.jpg
- **5 000 тг/мес** за тариф «Старт» — 1 магазин, 1 касса, 1 склад, без лимита товаров - **5 000 тг/мес** за тариф «Старт» — 1 магазин, 1 касса, 1 склад, без лимита товаров
- **90 дней триала** без карты, без обязательств, со всеми возможностями - **90 дней триала** без карты, без обязательств, со всеми возможностями
- **Касса для Windows** с нативной поддержкой Масса-К - **Касса для Windows** с нативной поддержкой торговых весов
- **Импорт каталога** из Excel, CSV или API за один клик - **Импорт каталога** из Excel, CSV или API за один клик
- **Все возможности на любом тарифе** — никаких доплат за CRM, лояльность, финансы или сценарии - **Все возможности на любом тарифе** — никаких доплат за CRM, лояльность, финансы или сценарии

View file

@ -4,7 +4,7 @@ date: 2026-04-30
author: Команда Food Market author: Команда Food Market
category: guide category: guide
description: "Пошаговая инструкция первого запуска: от регистрации до первой продажи." description: "Пошаговая инструкция первого запуска: от регистрации до первой продажи."
cover_image: /blog/quickstart.jpg cover_image: /photos/blog-quickstart.jpg
--- ---
# Запустите магазин в Food Market за 15 минут # Запустите магазин в Food Market за 15 минут
@ -46,7 +46,7 @@ cover_image: /blog/quickstart.jpg
1. Запустите .msi файл — нажмите «Далее» три раза 1. Запустите .msi файл — нажмите «Далее» три раза
2. Введите ключ организации (показан в админке) 2. Введите ключ организации (показан в админке)
3. Подключите весы Масса-К через USB-COM 3. Подключите торговые весы через USB-COM
4. Подключите сканер штрихкодов через USB 4. Подключите сканер штрихкодов через USB
5. Подключите чековый принтер 5. Подключите чековый принтер

View file

@ -44,7 +44,7 @@ order: 99
## Возможности ## Возможности
### Поддерживаете весы Масса-К? ### Поддерживаете торговые весы?
Да, нативно — без сторонних драйверов и переходников. Да, нативно — без сторонних драйверов и переходников.
### Какие сканеры штрихкодов поддерживаете? ### Какие сканеры штрихкодов поддерживаете?

View file

@ -49,14 +49,14 @@ order: 1
## Подключение оборудования ## Подключение оборудования
### Весы Масса-К ### Торговые весы
1. Подключите весы к компьютеру через кабель RS-232 или USB-COM 1. Подключите весы к компьютеру через кабель RS-232 или USB-COM
2. В кассе откройте «Настройки» → «Оборудование» → «Весы» 2. В кассе откройте «Настройки» → «Оборудование» → «Весы»
3. Нажмите «Найти весы» — касса опросит COM-порты и распознает Масса-К автоматически 3. Нажмите «Найти весы» — касса опросит COM-порты и распознает подключённую модель автоматически
4. Поставьте на чашу любой груз — должна появиться цифра в нижней панели кассы 4. Поставьте на чашу любой груз — должна появиться цифра в нижней панели кассы
Поддерживаемые модели: МК-А, МК-В, MK-T, MK-D и совместимые. Поддерживаются электронные весы стандартных протоколов.
### Сканер штрихкодов ### Сканер штрихкодов

View file

@ -39,7 +39,7 @@ order: 1
«Касса» → «Скачать установщик». Запустите .msi файл, нажмите «Далее» три раза. После установки введите ключ организации (показан в разделе «Касса»). «Касса» → «Скачать установщик». Запустите .msi файл, нажмите «Далее» три раза. После установки введите ключ организации (показан в разделе «Касса»).
Подключите оборудование: Подключите оборудование:
- Весы Масса-К — через RS-232 или USB-COM - Электронные торговые весы — через RS-232 или USB-COM
- Сканер штрихкодов — USB - Сканер штрихкодов — USB
- Чековый принтер — USB или сетевой - Чековый принтер — USB или сетевой

View file

@ -8,7 +8,7 @@ interface Props {
description?: string description?: string
ogImage?: string ogImage?: string
} }
const { title, description = 'Программа учёта и касса для розничных магазинов в Казахстане. Бесплатно 90 дней.', ogImage = '/og.png' } = Astro.props const { title, description = 'Программа учёта и касса для розничных магазинов в Казахстане. Бесплатно 90 дней.', ogImage = '/og/home.png' } = Astro.props
const canonical = new URL(Astro.url.pathname, Astro.site).toString() const canonical = new URL(Astro.url.pathname, Astro.site).toString()
--- ---
<!doctype html> <!doctype html>
@ -31,6 +31,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site).toString()
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${title} · Food Market`} /> <meta name="twitter:title" content={`${title} · Food Market`} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(ogImage, Astro.site).toString()} />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View file

@ -1,11 +1,13 @@
--- ---
import BaseLayout from '@/layouts/BaseLayout.astro' import BaseLayout from '@/layouts/BaseLayout.astro'
--- ---
<BaseLayout title="О нас" description="Food Market — программа учёта и касса для розничных магазинов Казахстана. История, команда, принципы."> <BaseLayout title="О нас" description="Food Market — программа учёта и касса для розничных магазинов Казахстана. История, команда, принципы." ogImage="/og/about.png">
<section class="bg-gradient-to-br from-brand-light/30 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/about-hero.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<h1 class="text-4xl font-extrabold">О Food Market</h1> <div class="absolute inset-0 bg-gradient-to-r from-slate-900/85 via-slate-900/60 to-transparent"></div>
<p class="mt-4 text-lg text-slate-600">Программа учёта розничной торговли, созданная в Казахстане для казахстанских магазинов.</p> <div class="relative max-w-3xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<h1 class="text-4xl lg:text-5xl font-extrabold text-white drop-shadow-lg">О Food Market</h1>
<p class="mt-4 text-lg text-emerald-50 max-w-xl">Программа учёта розничной торговли, созданная в Казахстане для казахстанских магазинов.</p>
</div> </div>
</section> </section>
@ -13,12 +15,12 @@ import BaseLayout from '@/layouts/BaseLayout.astro'
<h2>История</h2> <h2>История</h2>
<p>Food Market запустился в 2026 году как ответ на запрос рынка: розничные магазины в Казахстане заслуживают современный, быстрый и честный инструмент для ведения бизнеса.</p> <p>Food Market запустился в 2026 году как ответ на запрос рынка: розничные магазины в Казахстане заслуживают современный, быстрый и честный инструмент для ведения бизнеса.</p>
<p>Команда основателей — практики розницы и разработчики, которые три года изнутри наблюдали как магазины ведут учёт, какие проблемы решают каждый день, чего им не хватает в существующих инструментах.</p> <p>Команда основателей — практики розницы и разработчики, которые три года изнутри наблюдали как магазины ведут учёт, какие проблемы решают каждый день, чего им не хватает в существующих инструментах.</p>
<p>Мы поставили перед собой задачу сделать продукт, который работает с первого клика — без долгой настройки и платных консультаций; нативно поддерживает локальное железо (Масса-К из коробки, без переходников); соответствует реалиям РК (Kaspi Pay, ОФД, законодательство, поддержка на русском); и который можно попробовать без риска — настоящий триал, без привязки карты.</p> <p>Мы поставили перед собой задачу сделать продукт, который работает с первого клика — без долгой настройки и платных консультаций; нативно поддерживает локальное железо (торговые весы из коробки, без переходников); соответствует реалиям РК (Kaspi Pay, ОФД, законодательство, поддержка на русском); и который можно попробовать без риска — настоящий триал, без привязки карты.</p>
<h2>Почему мы</h2> <h2>Почему мы</h2>
<p>Мы делаем продукт, который работает с первого клика, стоит честно, поддерживает локальное оборудование и казахстанские интеграции, и который можно попробовать без риска.</p> <p>Мы делаем продукт, который работает с первого клика, стоит честно, поддерживает локальное оборудование и казахстанские интеграции, и который можно попробовать без риска.</p>
<ul> <ul>
<li><strong>Касса с весами Масса-К из коробки</strong> — без переходников и сторонних драйверов.</li> <li><strong>Касса с поддержкой торговых весов из коробки</strong> — без переходников и сторонних драйверов.</li>
<li><strong>Импорт каталога одной кнопкой</strong> — старый учёт переносится автоматически.</li> <li><strong>Импорт каталога одной кнопкой</strong> — старый учёт переносится автоматически.</li>
<li><strong>90 дней бесплатно без банковской карты</strong> — никаких автосписаний и сюрпризов.</li> <li><strong>90 дней бесплатно без банковской карты</strong> — никаких автосписаний и сюрпризов.</li>
<li><strong>Все модули в одном тарифе</strong> — CRM, лояльность, финансы, склад, аналитика включены.</li> <li><strong>Все модули в одном тарифе</strong> — CRM, лояльность, финансы, склад, аналитика включены.</li>

View file

@ -11,6 +11,11 @@ const { Content } = await post.render()
const fmt = post.data.date.toLocaleDateString('ru-KZ', { day: '2-digit', month: 'long', year: 'numeric' }) const fmt = post.data.date.toLocaleDateString('ru-KZ', { day: '2-digit', month: 'long', year: 'numeric' })
--- ---
<BaseLayout title={post.data.title} description={post.data.description}> <BaseLayout title={post.data.title} description={post.data.description}>
{post.data.cover_image && (
<div class="w-full overflow-hidden bg-slate-100" style="max-height: 480px;">
<img src={post.data.cover_image} alt="" class="w-full h-full object-cover" loading="eager" />
</div>
)}
<article class="max-w-[720px] mx-auto px-4 sm:px-6 py-12"> <article class="max-w-[720px] mx-auto px-4 sm:px-6 py-12">
<a href="/blog" class="text-xs text-brand hover:underline">← Все посты</a> <a href="/blog" class="text-xs text-brand hover:underline">← Все посты</a>
<p class="text-xs text-slate-500 mt-4">{fmt}</p> <p class="text-xs text-slate-500 mt-4">{fmt}</p>

View file

@ -2,7 +2,7 @@
import BaseLayout from '@/layouts/BaseLayout.astro' import BaseLayout from '@/layouts/BaseLayout.astro'
const sections = [ const sections = [
{ id: 'catalog', icon: '📦', title: 'Товары и каталог', items: ['Иерархические группы и подгруппы','Штрихкоды EAN-13/EAN-8/Code128','Цены: розничная, оптовая, эталонная','Автогенерация артикула и штрихкода','Импорт из других систем','Картинки товаров','Атрибуты и характеристики'] }, { id: 'catalog', icon: '📦', title: 'Товары и каталог', items: ['Иерархические группы и подгруппы','Штрихкоды EAN-13/EAN-8/Code128','Цены: розничная, оптовая, эталонная','Автогенерация артикула и штрихкода','Импорт из других систем','Картинки товаров','Атрибуты и характеристики'] },
{ id: 'sales', icon: '💳', title: 'Продажи и касса', items: ['Касса для Windows','Поддержка весов Масса-К','Сканер штрихкодов USB','Чековые принтеры ESC/POS','Kaspi Pay интеграция','Скидки и акции','Возвраты на кассе'] }, { id: 'sales', icon: '💳', title: 'Продажи и касса', items: ['Касса для Windows','Поддержка торговых весов','Сканер штрихкодов USB','Чековые принтеры ESC/POS','Kaspi Pay интеграция','Скидки и акции','Возвраты на кассе'] },
{ id: 'stock', icon: '🏬', title: 'Склад и остатки', items: ['Несколько складов','Приёмка со сканером','Автоматический расчёт себестоимости','Инвентаризация','Списание (брак/просрочка)','Оприходование','Перемещения между складами','История движений'] }, { id: 'stock', icon: '🏬', title: 'Склад и остатки', items: ['Несколько складов','Приёмка со сканером','Автоматический расчёт себестоимости','Инвентаризация','Списание (брак/просрочка)','Оприходование','Перемещения между складами','История движений'] },
{ id: 'purchase', icon: '🚚', title: 'Закупки и поставщики', items: ['База контрагентов','Заказы поставщикам','Документы приёмки','Скользящее среднее себестоимости','Эталонная цена'] }, { id: 'purchase', icon: '🚚', title: 'Закупки и поставщики', items: ['База контрагентов','Заказы поставщикам','Документы приёмки','Скользящее среднее себестоимости','Эталонная цена'] },
{ id: 'crm', icon: '👥', title: 'Клиенты и лояльность', items: ['База клиентов','Скидочные карты (скоро)','Программы лояльности (скоро)','Сегментация (скоро)','SMS-рассылки (скоро)'] }, { id: 'crm', icon: '👥', title: 'Клиенты и лояльность', items: ['База клиентов','Скидочные карты (скоро)','Программы лояльности (скоро)','Сегментация (скоро)','SMS-рассылки (скоро)'] },

View file

@ -7,13 +7,15 @@ const features = [
{ icon: '🆔', title: 'Проверка возраста', text: 'Запрос подтверждения возраста на кассе перед продажей алкогольных позиций.' }, { icon: '🆔', title: 'Проверка возраста', text: 'Запрос подтверждения возраста на кассе перед продажей алкогольных позиций.' },
] ]
--- ---
<BaseLayout title="Для алкогольных магазинов" description="Food Market для алкоголя: акцизные марки, ЕГАИС-ready, контроль времени продаж, проверка возраста."> <BaseLayout title="Для алкогольных магазинов" description="Food Market для алкоголя: акцизные марки, ЕГАИС-ready, контроль времени продаж, проверка возраста." ogImage="/og/for-alcohol.png">
<section class="bg-gradient-to-br from-brand-light/30 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/vertical-alcohol.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/85 via-slate-900/60 to-transparent"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<span class="text-3xl">🍷</span> <span class="text-3xl">🍷</span>
<h1 class="text-4xl font-extrabold mt-2">Программа учёта для алкоголя</h1> <h1 class="text-4xl lg:text-5xl font-extrabold mt-2 text-white drop-shadow-lg">Программа учёта<br/>для алкоголя</h1>
<p class="mt-4 text-lg text-slate-600">Акцизные марки, ЕГАИС-ready, контроль времени продаж и возраста.</p> <p class="mt-4 text-lg text-emerald-50 max-w-xl">Акцизные марки, ЕГАИС-ready, контроль времени продаж и возраста.</p>
<a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-brand text-white font-semibold rounded-md">Начать бесплатно</a> <a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-white text-brand font-bold rounded-md shadow-lg">Начать бесплатно</a>
</div> </div>
</section> </section>
<section class="max-w-5xl mx-auto px-4 sm:px-6 py-14"> <section class="max-w-5xl mx-auto px-4 sm:px-6 py-14">

View file

@ -7,13 +7,15 @@ const features = [
{ icon: '📈', title: 'Анализ блюд', text: 'Какие блюда продаются, какие нет. Маржинальность по позиции.' }, { icon: '📈', title: 'Анализ блюд', text: 'Какие блюда продаются, какие нет. Маржинальность по позиции.' },
] ]
--- ---
<BaseLayout title="Для кафе и общепита" description="Food Market для кафе и общепита: модификаторы, комбо, тех.карты, заказы по столикам."> <BaseLayout title="Для кафе и общепита" description="Food Market для кафе и общепита: модификаторы, комбо, тех.карты, заказы по столикам." ogImage="/og/for-cafe.png">
<section class="bg-gradient-to-br from-brand-light/30 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/vertical-cafe.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/80 via-slate-900/55 to-transparent"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<span class="text-3xl">☕</span> <span class="text-3xl">☕</span>
<h1 class="text-4xl font-extrabold mt-2">Программа учёта для кафе и общепита</h1> <h1 class="text-4xl lg:text-5xl font-extrabold mt-2 text-white drop-shadow-lg">Программа учёта<br/>для кафе и общепита</h1>
<p class="mt-4 text-lg text-slate-600">Модификаторы, комбо, технологические карты, заказы по столикам. Списание ингредиентов автоматом.</p> <p class="mt-4 text-lg text-emerald-50 max-w-xl">Модификаторы, комбо, технологические карты, заказы по столикам. Списание ингредиентов автоматом.</p>
<a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-brand text-white font-semibold rounded-md">Начать бесплатно</a> <a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-white text-brand font-bold rounded-md shadow-lg">Начать бесплатно</a>
</div> </div>
</section> </section>
<section class="max-w-5xl mx-auto px-4 sm:px-6 py-14"> <section class="max-w-5xl mx-auto px-4 sm:px-6 py-14">

View file

@ -7,13 +7,15 @@ const features = [
{ icon: '🔄', title: 'Возвраты', text: 'Удобный возврат на кассе с возвратом денег или обменом на другой размер.' }, { icon: '🔄', title: 'Возвраты', text: 'Удобный возврат на кассе с возвратом денег или обменом на другой размер.' },
] ]
--- ---
<BaseLayout title="Для магазина одежды" description="Food Market для одежды: размерные сетки, цвета, маркетплейсы, удобные возвраты."> <BaseLayout title="Для магазина одежды" description="Food Market для одежды: размерные сетки, цвета, маркетплейсы, удобные возвраты." ogImage="/og/for-clothing.png">
<section class="bg-gradient-to-br from-brand-light/30 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/vertical-clothing.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/80 via-slate-900/55 to-transparent"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<span class="text-3xl">👔</span> <span class="text-3xl">👔</span>
<h1 class="text-4xl font-extrabold mt-2">Программа учёта для магазина одежды</h1> <h1 class="text-4xl lg:text-5xl font-extrabold mt-2 text-white drop-shadow-lg">Программа учёта<br/>для магазина одежды</h1>
<p class="mt-4 text-lg text-slate-600">Размерные сетки, цвета, выгрузка на маркетплейсы, удобные возвраты.</p> <p class="mt-4 text-lg text-emerald-50 max-w-xl">Размерные сетки, цвета, выгрузка на маркетплейсы, удобные возвраты.</p>
<a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-brand text-white font-semibold rounded-md">Начать бесплатно</a> <a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-white text-brand font-bold rounded-md shadow-lg">Начать бесплатно</a>
</div> </div>
</section> </section>
<section class="max-w-5xl mx-auto px-4 sm:px-6 py-14"> <section class="max-w-5xl mx-auto px-4 sm:px-6 py-14">

View file

@ -1,19 +1,21 @@
--- ---
import BaseLayout from '@/layouts/BaseLayout.astro' import BaseLayout from '@/layouts/BaseLayout.astro'
const features = [ const features = [
{ icon: '⚖️', title: 'Весовой товар', text: 'Касса принимает вес с весов Масса-К напрямую — стоимость пересчитывается сразу.' }, { icon: '⚖️', title: 'Весовой товар', text: 'Касса принимает вес с торговых весов напрямую — стоимость пересчитывается сразу.' },
{ icon: '📅', title: 'Скоропорт и сроки', text: 'Контроль сроков годности, уведомление за N дней, списание просрочки одним кликом.' }, { icon: '📅', title: 'Скоропорт и сроки', text: 'Контроль сроков годности, уведомление за N дней, списание просрочки одним кликом.' },
{ icon: '🏷️', title: 'Печать ценников и штрихкодов', text: 'Внутренняя нумерация EAN-13 для весовых товаров, автогенерация при заведении.' }, { icon: '🏷️', title: 'Печать ценников и штрихкодов', text: 'Внутренняя нумерация EAN-13 для весовых товаров, автогенерация при заведении.' },
{ icon: '📊', title: 'ABC-анализ', text: 'Ходовые и неходовые позиции по сумме / марже / штукам. Решения по матрице за минуту.' }, { icon: '📊', title: 'ABC-анализ', text: 'Ходовые и неходовые позиции по сумме / марже / штукам. Решения по матрице за минуту.' },
] ]
--- ---
<BaseLayout title="Для продуктового магазина" description="Food Market для продуктового магазина: весовой товар, штрихкоды, скоропорт, ABC-анализ."> <BaseLayout title="Для продуктового магазина" description="Food Market для продуктового магазина: весовой товар, штрихкоды, скоропорт, ABC-анализ." ogImage="/og/for-grocery.png">
<section class="bg-gradient-to-br from-brand-light/30 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/vertical-grocery.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/80 via-slate-900/55 to-transparent"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<span class="text-3xl">🛒</span> <span class="text-3xl">🛒</span>
<h1 class="text-4xl font-extrabold mt-2">Программа учёта для продуктового магазина</h1> <h1 class="text-4xl lg:text-5xl font-extrabold mt-2 text-white drop-shadow-lg">Программа учёта<br/>для продуктового магазина</h1>
<p class="mt-4 text-lg text-slate-600">Весовой товар, скоропорт, штрихкоды, касса с весами Масса-К из коробки. Импорт из другой системы.</p> <p class="mt-4 text-lg text-emerald-50 max-w-xl">Весовой товар, скоропорт, штрихкоды, касса с поддержкой торговых весов из коробки. Импорт из другой системы.</p>
<a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-brand text-white font-semibold rounded-md">Начать бесплатно</a> <a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-white text-brand font-bold rounded-md shadow-lg">Начать бесплатно</a>
</div> </div>
</section> </section>
<section class="max-w-5xl mx-auto px-4 sm:px-6 py-14"> <section class="max-w-5xl mx-auto px-4 sm:px-6 py-14">

View file

@ -7,13 +7,15 @@ const features = [
{ icon: '📤', title: 'Маркетплейсы', text: 'Выгрузка на Kaspi Magazin, синхронизация цен и остатков (скоро).' }, { icon: '📤', title: 'Маркетплейсы', text: 'Выгрузка на Kaspi Magazin, синхронизация цен и остатков (скоро).' },
] ]
--- ---
<BaseLayout title="Для магазина дом и быт" description="Food Market для магазина товаров для дома: гарантийные сроки, серийные номера, комплекты, маркетплейсы."> <BaseLayout title="Для магазина дом и быт" description="Food Market для магазина товаров для дома: гарантийные сроки, серийные номера, комплекты, маркетплейсы." ogImage="/og/for-household.png">
<section class="bg-gradient-to-br from-brand-light/30 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/vertical-household.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/80 via-slate-900/55 to-transparent"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<span class="text-3xl">🏠</span> <span class="text-3xl">🏠</span>
<h1 class="text-4xl font-extrabold mt-2">Программа учёта для дома и быта</h1> <h1 class="text-4xl lg:text-5xl font-extrabold mt-2 text-white drop-shadow-lg">Программа учёта<br/>для дома и быта</h1>
<p class="mt-4 text-lg text-slate-600">Гарантийные сроки, серийные номера, комплекты, выгрузка на маркетплейсы.</p> <p class="mt-4 text-lg text-emerald-50 max-w-xl">Гарантийные сроки, серийные номера, комплекты, выгрузка на маркетплейсы.</p>
<a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-brand text-white font-semibold rounded-md">Начать бесплатно</a> <a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-white text-brand font-bold rounded-md shadow-lg">Начать бесплатно</a>
</div> </div>
</section> </section>
<section class="max-w-5xl mx-auto px-4 sm:px-6 py-14"> <section class="max-w-5xl mx-auto px-4 sm:px-6 py-14">

View file

@ -7,13 +7,15 @@ const features = [
{ icon: '🔒', title: 'Контроль доступа', text: 'Роли «Фармацевт» и «Заведующий» с разными правами. Логирование всех операций.' }, { icon: '🔒', title: 'Контроль доступа', text: 'Роли «Фармацевт» и «Заведующий» с разными правами. Логирование всех операций.' },
] ]
--- ---
<BaseLayout title="Для аптеки" description="Food Market для аптеки: партионный учёт, серии и сроки годности, рецептурные препараты, МНН-группы."> <BaseLayout title="Для аптеки" description="Food Market для аптеки: партионный учёт, серии и сроки годности, рецептурные препараты, МНН-группы." ogImage="/og/for-pharmacy.png">
<section class="bg-gradient-to-br from-brand-light/30 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/vertical-pharmacy.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/80 via-slate-900/55 to-transparent"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<span class="text-3xl">💊</span> <span class="text-3xl">💊</span>
<h1 class="text-4xl font-extrabold mt-2">Программа учёта для аптеки</h1> <h1 class="text-4xl lg:text-5xl font-extrabold mt-2 text-white drop-shadow-lg">Программа учёта<br/>для аптеки</h1>
<p class="mt-4 text-lg text-slate-600">Серии и сроки годности, рецептурный отпуск, группировка по МНН. Импорт из другой системы.</p> <p class="mt-4 text-lg text-emerald-50 max-w-xl">Серии и сроки годности, рецептурный отпуск, группировка по МНН. Импорт из другой системы.</p>
<a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-brand text-white font-semibold rounded-md">Начать бесплатно</a> <a href="/signup?plan=start" class="inline-block mt-7 px-5 py-3 bg-white text-brand font-bold rounded-md shadow-lg">Начать бесплатно</a>
</div> </div>
</section> </section>
<section class="max-w-5xl mx-auto px-4 sm:px-6 py-14"> <section class="max-w-5xl mx-auto px-4 sm:px-6 py-14">

View file

@ -35,17 +35,21 @@ const faq = [
{ q: 'Что если возникли проблемы?', a: 'Напишите на support@food-market.kz — поможем разобраться и при необходимости настроим импорт вручную.' }, { q: 'Что если возникли проблемы?', a: 'Напишите на support@food-market.kz — поможем разобраться и при необходимости настроим импорт вручную.' },
] ]
--- ---
<BaseLayout title="Импорт каталога" description="Перенесите товары, контрагентов, остатки и штрихкоды из вашей текущей системы за минуты. Excel, CSV, API."> <BaseLayout title="Импорт каталога" description="Перенесите товары, контрагентов, остатки и штрихкоды из вашей текущей системы за минуты. Excel, CSV, API." ogImage="/og/import.png">
<section class="bg-gradient-to-br from-brand-light/40 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/import-hero.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<span class="inline-block text-xs uppercase tracking-wider text-brand font-semibold">Импорт</span> <div class="absolute inset-0 bg-gradient-to-r from-slate-900/85 via-slate-900/60 to-slate-900/30"></div>
<h1 class="text-4xl font-extrabold mt-2">Перенесите каталог<br/>за один клик</h1> <div class="relative max-w-7xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<p class="mt-4 max-w-xl text-slate-600"> <div class="max-w-2xl">
Не перепечатывайте товары вручную. Импортируйте каталог из вашей текущей системы за минуты. <span class="inline-block text-xs uppercase tracking-wider text-emerald-300 font-semibold">Импорт</span>
</p> <h1 class="text-4xl lg:text-5xl font-extrabold mt-2 text-white drop-shadow-lg">Перенесите каталог<br/>за один клик</h1>
<div class="mt-7 flex gap-3"> <p class="mt-4 text-lg text-emerald-50">
<a href="/signup" class="px-5 py-3 bg-brand text-white text-sm font-semibold rounded-md hover:bg-brand-hover">Начать импорт</a> Не перепечатывайте товары вручную. Импортируйте каталог из вашей текущей системы за минуты.
<a href="#formats" class="px-5 py-3 border border-slate-300 rounded-md font-semibold hover:bg-slate-50">Поддерживаемые форматы</a> </p>
<div class="mt-7 flex gap-3 flex-wrap">
<a href="/signup" class="px-5 py-3 bg-white text-brand text-sm font-bold rounded-md shadow-lg">Начать импорт</a>
<a href="#formats" class="px-5 py-3 border border-white/40 text-white rounded-md font-semibold hover:bg-white/10">Поддерживаемые форматы</a>
</div>
</div> </div>
</div> </div>
</section> </section>

View file

@ -13,7 +13,7 @@ const verticals = [
const modules = [ const modules = [
{ icon: '📦', title: 'Товары и каталог', text: 'Группы, штрихкоды, цены, остатки, импорт каталога одной кнопкой.' }, { icon: '📦', title: 'Товары и каталог', text: 'Группы, штрихкоды, цены, остатки, импорт каталога одной кнопкой.' },
{ icon: '💳', title: 'Продажи и касса', text: 'Касса для Windows с поддержкой весов Масса-К, штрихкодов, чековых принтеров.' }, { icon: '💳', title: 'Продажи и касса', text: 'Касса для Windows с поддержкой торговых весов, штрихкодов, чековых принтеров.' },
{ icon: '🏬', title: 'Склад и приёмки', text: 'Несколько складов, приёмка со сканером, инвентаризация, списание, оприходование.' }, { icon: '🏬', title: 'Склад и приёмки', text: 'Несколько складов, приёмка со сканером, инвентаризация, списание, оприходование.' },
{ icon: '🚚', title: 'Закупки и поставщики', text: 'Документы приёмки с автоматическим расчётом себестоимости (скользящее среднее).' }, { icon: '🚚', title: 'Закупки и поставщики', text: 'Документы приёмки с автоматическим расчётом себестоимости (скользящее среднее).' },
{ icon: '👥', title: 'Клиенты и лояльность', text: 'База контрагентов, скидки, акции, программы лояльности (скоро).' }, { icon: '👥', title: 'Клиенты и лояльность', text: 'База контрагентов, скидки, акции, программы лояльности (скоро).' },
@ -27,7 +27,7 @@ const integrations = [
<BaseLayout <BaseLayout
title="Программа учёта для розничных магазинов" title="Программа учёта для розничных магазинов"
description="Программа учёта и касса для розничных магазинов в Казахстане. Касса с весами Масса-К, импорт каталога одной кнопкой, интеграция с Kaspi и ОФД РК. Бесплатно 90 дней без карты." description="Программа учёта и касса для розничных магазинов в Казахстане. Касса с поддержкой торговых весов, импорт каталога одной кнопкой, интеграция с Kaspi и ОФД РК. Бесплатно 90 дней без карты."
> >
{/* 1. Hero */} {/* 1. Hero */}
<section class="bg-gradient-to-br from-brand-light/40 via-white to-white"> <section class="bg-gradient-to-br from-brand-light/40 via-white to-white">
@ -37,7 +37,7 @@ const integrations = [
Программа учёта и&nbsp;касса для розничных магазинов в&nbsp;Казахстане Программа учёта и&nbsp;касса для розничных магазинов в&nbsp;Казахстане
</h1> </h1>
<p class="mt-5 text-lg text-slate-600"> <p class="mt-5 text-lg text-slate-600">
Касса с весами Масса-К, импорт каталога одной кнопкой, интеграция с Kaspi и ОФД РК. Касса с поддержкой торговых весов, импорт каталога одной кнопкой, интеграция с Kaspi и ОФД РК.
<strong>Бесплатно 90 дней без карты.</strong> <strong>Бесплатно 90 дней без карты.</strong>
</p> </p>
<div class="mt-7 flex flex-wrap gap-3"> <div class="mt-7 flex flex-wrap gap-3">
@ -46,14 +46,13 @@ const integrations = [
</div> </div>
<p class="mt-3 text-xs text-slate-500">Без банковской карты · 90 дней триал · Подписка от 5 000 ₸/мес</p> <p class="mt-3 text-xs text-slate-500">Без банковской карты · 90 дней триал · Подписка от 5 000 ₸/мес</p>
</div> </div>
<div class="rounded-xl border border-slate-200 bg-white shadow-xl overflow-hidden"> <div class="rounded-2xl border border-emerald-100 bg-white shadow-2xl overflow-hidden">
{/* Реальный скриншот админки. Пока placeholder с типографическим mock'ом дашборда. */} <div class="bg-slate-100 border-b border-slate-200 px-3 py-2 flex gap-1.5">
<div class="aspect-[16/10] bg-gradient-to-br from-slate-50 to-slate-100 p-4 grid grid-cols-2 gap-3"> <span class="w-3 h-3 rounded-full bg-red-400"></span>
<div class="bg-white rounded-lg border border-slate-200 p-3"><div class="text-[10px] uppercase text-slate-400">Организаций</div><div class="text-2xl font-bold mt-1">2</div></div> <span class="w-3 h-3 rounded-full bg-yellow-400"></span>
<div class="bg-white rounded-lg border border-slate-200 p-3"><div class="text-[10px] uppercase text-slate-400">Товаров</div><div class="text-2xl font-bold mt-1">29 540</div></div> <span class="w-3 h-3 rounded-full bg-green-400"></span>
<div class="bg-white rounded-lg border border-slate-200 p-3"><div class="text-[10px] uppercase text-slate-400">Приёмок / мес</div><div class="text-2xl font-bold mt-1">187</div></div>
<div class="bg-white rounded-lg border border-slate-200 p-3"><div class="text-[10px] uppercase text-slate-400">Чеков сегодня</div><div class="text-2xl font-bold mt-1 text-brand">432</div></div>
</div> </div>
<img src="/screenshots/dashboard-hero.png" alt="Дашборд Food Market" class="w-full block" loading="eager" />
</div> </div>
</div> </div>
</section> </section>
@ -63,7 +62,7 @@ const integrations = [
<div class="rounded-xl border border-slate-200 p-5"> <div class="rounded-xl border border-slate-200 p-5">
<div class="text-3xl">🛒</div> <div class="text-3xl">🛒</div>
<h3 class="font-semibold mt-2">Касса с весами из коробки</h3> <h3 class="font-semibold mt-2">Касса с весами из коробки</h3>
<p class="text-sm text-slate-600 mt-1">Прямая поддержка Масса-К без переходников и драйверов. Подключите весы — Food Market распознает их автоматически. Поддержка штрихкодов, скидок, акций, лояльности.</p> <p class="text-sm text-slate-600 mt-1">Прямая поддержка торговых весов без переходников и драйверов. Подключите весы — Food Market распознает их автоматически. Поддержка штрихкодов, скидок, акций, лояльности.</p>
</div> </div>
<div class="rounded-xl border border-slate-200 p-5"> <div class="rounded-xl border border-slate-200 p-5">
<div class="text-3xl">⬇️</div> <div class="text-3xl">⬇️</div>
@ -79,10 +78,16 @@ const integrations = [
{/* 3. Скриншот продукта */} {/* 3. Скриншот продукта */}
<section id="screenshot" class="max-w-7xl mx-auto px-4 sm:px-6 py-12"> <section id="screenshot" class="max-w-7xl mx-auto px-4 sm:px-6 py-12">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-6"> <h2 class="text-3xl font-bold text-center mb-2">Один экран — весь магазин</h2>
<div class="aspect-[16/9] bg-white rounded-lg border border-slate-200 flex items-center justify-center text-slate-400 text-sm"> <p class="text-center text-slate-500 mb-8">Дашборд показывает выручку, остатки, движения и топ-товары. Без переключения вкладок и Excel-экспорта.</p>
Скриншот админки · реальный экран /dashboard <div class="rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-6 shadow-lg">
<div class="bg-slate-200 rounded-t-md px-3 py-2 flex gap-1.5 items-center">
<span class="w-2.5 h-2.5 rounded-full bg-red-400"></span>
<span class="w-2.5 h-2.5 rounded-full bg-yellow-400"></span>
<span class="w-2.5 h-2.5 rounded-full bg-green-400"></span>
<span class="ml-3 text-xs text-slate-500">app.food-market.kz / catalog / products</span>
</div> </div>
<img src="/screenshots/catalog.png" alt="Каталог товаров Food Market — 29 540 SKU" class="w-full block rounded-b-md" loading="lazy" />
</div> </div>
</section> </section>
@ -124,7 +129,7 @@ const integrations = [
<section class="max-w-7xl mx-auto px-4 sm:px-6 py-14 grid lg:grid-cols-2 gap-10 items-center"> <section class="max-w-7xl mx-auto px-4 sm:px-6 py-14 grid lg:grid-cols-2 gap-10 items-center">
<div> <div>
<span class="inline-block text-xs uppercase tracking-wider text-brand font-semibold">Касса для Windows</span> <span class="inline-block text-xs uppercase tracking-wider text-brand font-semibold">Касса для Windows</span>
<h2 class="text-3xl font-bold mt-2">Кассовая программа с поддержкой весов Масса-К</h2> <h2 class="text-3xl font-bold mt-2">Кассовая программа с поддержкой торговых весов</h2>
<p class="mt-4 text-slate-600"> <p class="mt-4 text-slate-600">
Установщик для Windows 10/11. Работает офлайн, синхронизируется с админкой, Установщик для Windows 10/11. Работает офлайн, синхронизируется с админкой,
принимает Kaspi Pay, печатает фискальные чеки в ОФД РК. принимает Kaspi Pay, печатает фискальные чеки в ОФД РК.
@ -134,19 +139,24 @@ const integrations = [
<span class="px-5 py-3 border border-slate-300 text-sm font-semibold rounded-md text-slate-400 cursor-not-allowed" title="В разработке">Скачать установщик</span> <span class="px-5 py-3 border border-slate-300 text-sm font-semibold rounded-md text-slate-400 cursor-not-allowed" title="В разработке">Скачать установщик</span>
</div> </div>
</div> </div>
<div class="aspect-video rounded-xl border border-slate-200 bg-slate-900 flex items-center justify-center text-slate-500 text-sm"> <div class="aspect-video rounded-xl border border-slate-200 bg-cover bg-center relative overflow-hidden shadow-xl" style="background-image: url('/photos/pos-hero.jpg')">
Видео работы кассы · скоро <div class="absolute inset-0 bg-slate-900/30"></div>
<div class="absolute bottom-4 left-4 right-4 bg-white/95 backdrop-blur rounded-md px-4 py-3 text-sm">
<strong class="text-brand">Касса для Windows</strong> · работает офлайн, поддерживает торговые весы и Kaspi Pay
</div>
</div> </div>
</section> </section>
{/* 7. Интеграции */} {/* 7. Интеграции */}
<section class="bg-slate-50 py-14"> <section class="relative py-14 overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6"> <img src="/photos/integrations.jpg" alt="" class="absolute inset-0 w-full h-full object-cover opacity-15" />
<div class="absolute inset-0 bg-gradient-to-b from-slate-50/95 via-white/90 to-slate-50/95"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6">
<h2 class="text-3xl font-bold text-center mb-2">Интеграции</h2> <h2 class="text-3xl font-bold text-center mb-2">Интеграции</h2>
<p class="text-center text-slate-500 mb-8">Кассы, банки, ОФД, маркетплейсы. Подключаем по запросу.</p> <p class="text-center text-slate-500 mb-8">Кассы, банки, ОФД, маркетплейсы. Подключаем по запросу.</p>
<div class="flex flex-wrap justify-center gap-3"> <div class="flex flex-wrap justify-center gap-3">
{integrations.map((label) => ( {integrations.map((label) => (
<span class="px-4 py-2 bg-white border border-slate-200 rounded-md text-sm font-medium">{label}</span> <span class="px-4 py-2 bg-white border border-slate-200 rounded-md text-sm font-medium shadow-sm">{label}</span>
))} ))}
</div> </div>
</div> </div>
@ -216,11 +226,13 @@ const integrations = [
</section> </section>
{/* 11. Финальный CTA */} {/* 11. Финальный CTA */}
<section class="bg-brand-light/30 py-16"> <section class="relative py-20 overflow-hidden">
<div class="max-w-3xl mx-auto px-4 sm:px-6 text-center"> <img src="/photos/cta-banner.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<h2 class="text-3xl sm:text-4xl font-extrabold">Запустите магазин за 15 минут</h2> <div class="absolute inset-0 bg-emerald-700/75"></div>
<p class="mt-3 text-slate-600">Регистрация — минута. Импорт товаров — десять минут. Касса работает на следующий день.</p> <div class="relative max-w-3xl mx-auto px-4 sm:px-6 text-center">
<a href="/signup" class="inline-block mt-7 px-7 py-3 bg-brand text-white font-semibold rounded-md hover:bg-brand-hover">Начать бесплатно — 90 дней</a> <h2 class="text-3xl sm:text-4xl font-extrabold text-white drop-shadow">Запустите магазин за 15 минут</h2>
<p class="mt-3 text-emerald-50 text-lg">Регистрация — минута. Импорт товаров — десять минут. Касса работает на следующий день.</p>
<a href="/signup" class="inline-block mt-7 px-7 py-3 bg-white text-brand font-bold rounded-md shadow-lg hover:bg-emerald-50">Начать бесплатно — 90 дней</a>
</div> </div>
</section> </section>
</BaseLayout> </BaseLayout>

View file

@ -4,8 +4,8 @@ const groups = [
{ title: 'Платёжные системы', items: ['Kaspi Pay', 'Halyk Bank', 'Jusan Bank', 'Forte Bank'] }, { title: 'Платёжные системы', items: ['Kaspi Pay', 'Halyk Bank', 'Jusan Bank', 'Forte Bank'] },
{ title: 'Фискализация', items: ['ОФД РК (все операторы)', 'Чековые принтеры ESC/POS'] }, { title: 'Фискализация', items: ['ОФД РК (все операторы)', 'Чековые принтеры ESC/POS'] },
{ title: 'Маркетплейсы (скоро)', items: ['Kaspi Magazin', 'Ozon', 'Wildberries'] }, { title: 'Маркетплейсы (скоро)', items: ['Kaspi Magazin', 'Ozon', 'Wildberries'] },
{ title: 'Учётные системы', items: ['другие системы (импорт каталога)', 'Excel CSV-импорт'] }, { title: 'Учётные системы', items: ['Импорт из других систем по API', 'Excel/CSV-импорт', 'Выгрузка 1С (XML/Excel)'] },
{ title: 'Оборудование', items: ['Весы Масса-К (USB/COM)', 'Сканеры штрихкодов USB', 'Денежные ящики', 'Дисплей покупателя'] }, { title: 'Оборудование', items: ['Электронные торговые весы (USB/COM)', 'Сканеры штрихкодов USB', 'Денежные ящики', 'Дисплей покупателя'] },
] ]
--- ---
<BaseLayout title="Интеграции" description="Кассы, банки, ОФД, маркетплейсы, учётные системы — все интеграции Food Market."> <BaseLayout title="Интеграции" description="Кассы, банки, ОФД, маркетплейсы, учётные системы — все интеграции Food Market.">

View file

@ -1,7 +1,7 @@
--- ---
import BaseLayout from '@/layouts/BaseLayout.astro' import BaseLayout from '@/layouts/BaseLayout.astro'
const features = [ const features = [
{ icon: '⚖️', title: 'Нативная поддержка весов Масса-К', text: 'Подключите весы к компьютеру кассира — Food Market распознаёт их автоматически. Поддерживаемые модели: МК-А, МК-В, MK-T, MK-D и совместимые. Подключение через RS-232 или USB-COM. Никаких сторонних драйверов.' }, { icon: '⚖️', title: 'Нативная поддержка торговых весов', text: 'Подключите весы к компьютеру кассира — Food Market распознаёт их автоматически. Поддерживаются электронные весы стандартных протоколов через RS-232 или USB-COM. Никаких сторонних драйверов.' },
{ icon: '📡', title: 'Офлайн-режим без потери данных', text: 'Интернет пропал — касса продолжает работать. Все чеки записываются локально. Когда связь вернётся — данные подтянутся в облако автоматически.' }, { icon: '📡', title: 'Офлайн-режим без потери данных', text: 'Интернет пропал — касса продолжает работать. Все чеки записываются локально. Когда связь вернётся — данные подтянутся в облако автоматически.' },
{ icon: '🛒', title: 'Сканеры штрихкодов любых производителей', text: 'Поддерживаем все стандартные модели работающие как клавиатура (HID-эмуляция). Подключили — и работает. EAN-13/EAN-8/Code128/Datamatrix.' }, { icon: '🛒', title: 'Сканеры штрихкодов любых производителей', text: 'Поддерживаем все стандартные модели работающие как клавиатура (HID-эмуляция). Подключили — и работает. EAN-13/EAN-8/Code128/Datamatrix.' },
{ icon: '🖨️', title: 'Печать чеков на ОКП-совместимом оборудовании', text: 'Любые ОКП РК совместимые принтеры. Драйверы устанавливаются автоматически.' }, { icon: '🖨️', title: 'Печать чеков на ОКП-совместимом оборудовании', text: 'Любые ОКП РК совместимые принтеры. Драйверы устанавливаются автоматически.' },
@ -11,7 +11,7 @@ const features = [
] ]
const hardware = [ const hardware = [
['Компьютер', 'Windows 10+ (32/64 bit), 4 GB RAM, 5 GB на диске'], ['Компьютер', 'Windows 10+ (32/64 bit), 4 GB RAM, 5 GB на диске'],
['Весы', 'Масса-К: МК-А, МК-В, MK-T, MK-D (RS-232/USB)'], ['Весы', 'Электронные торговые весы стандартных протоколов (RS-232/USB)'],
['Сканер штрихкодов', 'Любой HID-совместимый USB-сканер'], ['Сканер штрихкодов', 'Любой HID-совместимый USB-сканер'],
['Чековый принтер', 'Любой ОКП РК совместимый принтер'], ['Чековый принтер', 'Любой ОКП РК совместимый принтер'],
['Денежный ящик', 'Подключение через принтер чеков (RJ-11)'], ['Денежный ящик', 'Подключение через принтер чеков (RJ-11)'],
@ -25,23 +25,34 @@ const steps = [
['Откройте смену', 'И пробивайте первый чек'], ['Откройте смену', 'И пробивайте первый чек'],
] ]
--- ---
<BaseLayout title="Касса для Windows" description="Кассовая программа Food Market для Windows: поддержка весов Масса-К, офлайн-режим, фискальные чеки ОФД РК, Kaspi Pay."> <BaseLayout title="Касса для Windows" description="Кассовая программа Food Market для Windows: поддержка торговых весов, офлайн-режим, фискальные чеки ОФД РК, Kaspi Pay." ogImage="/og/pos.png">
<section class="bg-gradient-to-br from-brand-light/40 via-white to-white"> <section class="relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-14"> <img src="/photos/pos-hero.jpg" alt="" class="absolute inset-0 w-full h-full object-cover" />
<span class="inline-block text-xs uppercase tracking-wider text-brand font-semibold">Касса</span> <div class="absolute inset-0 bg-gradient-to-r from-slate-900/85 via-slate-900/65 to-slate-900/35"></div>
<h1 class="text-4xl font-extrabold mt-2">Касса для Windows<br/>с поддержкой весов Масса-К</h1> <div class="relative max-w-7xl mx-auto px-4 sm:px-6 py-20 lg:py-28">
<p class="mt-4 max-w-xl text-slate-600"> <div class="max-w-2xl bg-white/95 backdrop-blur p-8 rounded-2xl shadow-2xl border border-slate-200">
Установите за 5 минут. Работайте офлайн. Подключайте стандартное оборудование без переходников и сторонних драйверов. <span class="inline-block text-xs uppercase tracking-wider text-brand font-semibold">Касса</span>
</p> <h1 class="text-4xl font-extrabold mt-2">Касса для Windows<br/>с поддержкой торговых весов</h1>
<div class="mt-7 flex gap-3"> <p class="mt-4 text-slate-600">
<span class="px-5 py-3 bg-slate-200 text-slate-500 rounded-md font-semibold cursor-not-allowed" title="В разработке">Скачать кассу (Windows 10+)</span> Установите за 5 минут. Работайте офлайн. Подключайте стандартное оборудование без переходников и сторонних драйверов.
<a href="/pricing" class="px-5 py-3 border border-slate-300 rounded-md font-semibold hover:bg-slate-50">Тарифы</a> </p>
<div class="mt-7 flex gap-3 flex-wrap">
<span class="px-5 py-3 bg-slate-200 text-slate-500 rounded-md font-semibold cursor-not-allowed" title="В разработке">Скачать кассу (Windows 10+)</span>
<a href="/pricing" class="px-5 py-3 border border-slate-300 rounded-md font-semibold hover:bg-slate-50">Тарифы</a>
</div>
</div> </div>
</div> </div>
</section> </section>
<section class="max-w-7xl mx-auto px-4 sm:px-6 py-14"> <section class="max-w-7xl mx-auto px-4 sm:px-6 py-14">
<div class="aspect-video rounded-xl bg-slate-900 flex items-center justify-center text-slate-500">Видео работы кассы · скоро</div> <div class="rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-6 shadow-lg">
<div class="bg-slate-200 rounded-t-md px-3 py-2 flex gap-1.5 items-center">
<span class="w-2.5 h-2.5 rounded-full bg-red-400"></span>
<span class="w-2.5 h-2.5 rounded-full bg-yellow-400"></span>
<span class="w-2.5 h-2.5 rounded-full bg-green-400"></span>
</div>
<img src="/screenshots/supply-form.png" alt="Окно приёмки в Food Market" class="w-full block rounded-b-md" loading="lazy" />
</div>
</section> </section>
<section class="max-w-7xl mx-auto px-4 sm:px-6 py-12"> <section class="max-w-7xl mx-auto px-4 sm:px-6 py-12">

View file

@ -2,10 +2,15 @@
import BaseLayout from '@/layouts/BaseLayout.astro' import BaseLayout from '@/layouts/BaseLayout.astro'
import BusinessTariffBuilder from '@/components/BusinessTariffBuilder.tsx' import BusinessTariffBuilder from '@/components/BusinessTariffBuilder.tsx'
--- ---
<BaseLayout title="Тарифы" description="Простые тарифы Food Market: Старт от 5 000 ₸/мес, Бизнес-конструктор, Сеть по запросу. 90 дней бесплатно без банковской карты."> <BaseLayout title="Тарифы" description="Простые тарифы Food Market: Старт от 5 000 ₸/мес, Бизнес-конструктор, Сеть по запросу. 90 дней бесплатно без банковской карты." ogImage="/og/pricing.png">
<section class="bg-gradient-to-br from-emerald-500 via-emerald-600 to-emerald-700 text-white py-16">
<div class="max-w-6xl mx-auto px-4 sm:px-6 text-center">
<h1 class="text-4xl lg:text-5xl font-extrabold drop-shadow">Прозрачные тарифы Food Market</h1>
<p class="text-emerald-50 mt-4 text-lg">Никаких скрытых доплат. CRM, финансы, лояльность — во всех тарифах.</p>
<p class="text-emerald-100/90 mt-2 text-sm">90 дней бесплатно. Без банковской карты. Отмена в любой момент.</p>
</div>
</section>
<section class="max-w-6xl mx-auto px-4 sm:px-6 py-12"> <section class="max-w-6xl mx-auto px-4 sm:px-6 py-12">
<h1 class="text-4xl font-extrabold text-center">Тарифы Food Market</h1>
<p class="text-center text-slate-500 mt-3">Никаких скрытых доплат. CRM, финансы, лояльность включены во все тарифы.</p>
<div class="grid lg:grid-cols-3 gap-5 mt-10"> <div class="grid lg:grid-cols-3 gap-5 mt-10">
<div class="rounded-xl border border-slate-200 p-6 bg-white"> <div class="rounded-xl border border-slate-200 p-6 bg-white">
@ -16,8 +21,8 @@ import BusinessTariffBuilder from '@/components/BusinessTariffBuilder.tsx'
<li>✓ 1 магазин · 1 касса · 1 склад</li> <li>✓ 1 магазин · 1 касса · 1 склад</li>
<li>✓ 2 сотрудника</li> <li>✓ 2 сотрудника</li>
<li>✓ Без лимита товаров</li> <li>✓ Без лимита товаров</li>
<li>✓ Касса с весами Масса-К</li> <li>✓ Касса с поддержкой торговых весов</li>
<li>✓ Импорт из другие системы</li> <li>✓ Импорт из других систем</li>
<li>✓ Интеграции Kaspi / ОФД</li> <li>✓ Интеграции Kaspi / ОФД</li>
<li class="text-slate-400">— API</li> <li class="text-slate-400">— API</li>
<li class="text-slate-400">— SLA</li> <li class="text-slate-400">— SLA</li>