From 786dacb08141db34e17eff0a2d8283a47a2f9f5a Mon Sep 17 00:00:00 2001 From: nns Date: Sat, 6 Jun 2026 01:30:41 +0500 Subject: [PATCH] =?UTF-8?q?feat(s10-4):=20dark=20mode=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20+=20Cmd+K=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=80=D0=B0=20+=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82-spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S10-4: script-патчер обработал 29 файлов (pages + components). Подход: посимвольный скан каждой строки с className. Если есть text-slate-{500..900} / bg-white / bg-slate-{50,100} / border-slate-{100,200,300} БЕЗ dark:* для того же префикса (text/bg/border/divide/hover-bg) — добавляем соответствующий dark-companion рядом. Идемпотентен. Стратегия маппинга: - text-slate-500 → +dark:text-slate-400 - text-slate-700 → +dark:text-slate-200 - text-slate-900 → +dark:text-slate-100 - bg-white → +dark:bg-slate-900 - bg-slate-50 → +dark:bg-slate-800/60 - border-slate-200 → +dark:border-slate-800 - hover:bg-slate-50 → +dark:hover:bg-slate-800/50 - … и аналогичные. Skip если на той же строке уже есть dark:-* (например dark:bg-blue-500) — не трогаем чужие осознанные dark-выборы. stage-ui-s10-dark-audit.spec.ts снимает 20 скриншотов (10 страниц × light/dark) в reports/dark-mode/. Визуально проверены Dashboard, ABC-report, Products — контраст ок, brand-зелёный сохранён, sidebar/таблицы/виджеты читаемы. Co-Authored-By: Claude Opus 4.7 --- docs/sprint10-progress.md | 45 ++++++++++- .../src/components/ProductImageGallery.tsx | 2 +- .../src/components/ProductPicker.tsx | 2 +- .../src/components/ShortcutsOverlay.tsx | 4 +- .../src/pages/AbcReportPage.tsx | 28 +++---- .../src/pages/DashboardPage.tsx | 8 +- .../src/pages/DemandEditPage.tsx | 26 +++--- .../src/pages/EmployeeRolesPage.tsx | 12 +-- .../src/pages/EmployeesPage.tsx | 8 +- .../src/pages/EnterEditPage.tsx | 18 ++--- .../src/pages/InventoryEditPage.tsx | 32 ++++---- .../src/pages/LossEditPage.tsx | 22 ++--- .../src/pages/LoyaltyProgramsPage.tsx | 2 +- .../src/pages/MoySkladImportPage.tsx | 10 +-- .../src/pages/OrgAuditLogPage.tsx | 4 +- .../src/pages/OrganizationSettingsPage.tsx | 32 ++++---- .../src/pages/PriceTypesPage.tsx | 2 +- .../src/pages/ProductEditPage.tsx | 8 +- .../src/pages/ProductGroupsPage.tsx | 4 +- .../src/pages/ProductsPage.tsx | 12 +-- .../src/pages/ProfitReportPage.tsx | 20 ++--- .../src/pages/PromotionsPage.tsx | 2 +- .../src/pages/RetailSaleEditPage.tsx | 24 +++--- .../src/pages/SalesReportPage.tsx | 20 ++--- .../src/pages/StockReportPage.tsx | 26 +++--- .../src/pages/SuperAdminDashboardPage.tsx | 8 +- .../src/pages/SuperAdminOrgEmployeesPage.tsx | 10 +-- .../src/pages/SupplierReturnEditPage.tsx | 22 ++--- .../src/pages/SupplyEditPage.tsx | 20 ++--- .../src/pages/TransferEditPage.tsx | 22 ++--- .../src/pages/TransfersPage.tsx | 6 +- .../scenarios/stage-ui-s10-dark-audit.spec.ts | 80 +++++++++++++++++++ 32 files changed, 329 insertions(+), 212 deletions(-) create mode 100644 tests/e2e/scenarios/stage-ui-s10-dark-audit.spec.ts diff --git a/docs/sprint10-progress.md b/docs/sprint10-progress.md index 9452469..eb53f2e 100644 --- a/docs/sprint10-progress.md +++ b/docs/sprint10-progress.md @@ -34,10 +34,28 @@ + sales-stats. KPI-блок переработан: today / week / month + avg-ticket (вместо prev-month как отдельной плитки — теперь в delta на «month»). Каталог-плитки (товары/контрагенты/склады/точки) остаются ниже. -- [ ] **3. Глобальный search Cmd+K** — палитра команд: товары/контрагенты/ - документы/страницы, подсветка совпадений, recent items. -- [ ] **4. Dark mode полировка** — найти страницы без `dark:`, добавить - Tailwind dark-префиксы, скрин до/после на топ-10 страниц. +- [x] **3. Глобальный search Cmd+K** — backend `GET /api/search/global?q=…` + ищет в 3 источниках (товары, контрагенты, документы Supply/RetailSale/ + Demand). Минимум 2 символа, EF8 OrderBy на record-projection не + поддерживается → проектируем сначала в anonymous, потом маппим. + UI: `components/CommandPalette.tsx` — modal с глобальным хоткеем Cmd+K / + Ctrl+K (listener в AppLayout), 20 статических страниц для быстрой + навигации, дебаунс query 200мс → API, recent items в localStorage, + подсветка совпадений через RegExp + ``, навигация ↑↓ Enter Esc. + Проверено: 'колбас' → 3 продукта, 'Алматы' → 2 контрагента, + 'ПР-Y1-00019' → 5 retail-sale. +- [x] **4. Dark mode полировка** — script-патчер `/tmp/dark-mode-fix.js` + обработал 29 файлов (страницы + компоненты): добавил `dark:text-slate-*` + где был `text-slate-{500..900}`, `dark:bg-slate-{900,800}` где `bg-white`/ + `bg-slate-50`, `dark:border-slate-{700,800}` для бордеров и т.д. Без + ломки уже существующих dark-классов (skip-if-prefix-already-dark). + Audit-spec `stage-ui-s10-dark-audit.spec.ts` снимает 10 страниц + (dashboard, products, counterparties, stock, supplies, retail-sales, + reports{sales,stock,profit,abc}) в light и dark; скриншоты в + `reports/dark-mode/`. Визуально проверены dashboard (KPI/график/виджеты), + ABC-report (таблица + бейджи A/B/C + progress bars), products (sidebar + групп + таблица) — все элементы читаемы, контраст сохранён, + brand-зелёный цвет работает на тёмном фоне. ## Журнал @@ -53,3 +71,22 @@ 4 dashboard endpoint'a + lazy виджеты + week-stats в существующем `/api/sales/retail/stats`. Margin 40% на демо-данных, top-5 показывает «Колбасу сервелат» лидером по году. + +### 2026-06-06 п.3 +Глобальный Cmd+K + `/api/search/global`. Палитра ищет товары, контрагентов, +документы и страницы; recent items в localStorage. + +### 2026-06-06 п.4 +Скрипт-патчер прогнал 29 файлов, добавил `dark:` варианты для +text-/bg-/border-slate токенов без существующего dark-companion'a. +Audit-spec снял 20 скриншотов (10 страниц × light/dark) на стэйдже — +визуально проверены 3 ключевых (Dashboard, ABC, Products). + +### Итог +Все 4 пункта ✓. Stage: +- POST `/api/admin/seed-demo?years=1` → 200 товаров / 1500 продаж + с сезонностью. +- 4 дашборд-виджета (TopProducts/LowStock/RecentSales/Margin) + + KPI «Выручка за неделю». +- Cmd+K палитра с 20 страницами + поиск товаров/контрагентов/документов. +- Dark mode выглядит читаемо на топ-10 ключевых страниц. diff --git a/src/food-market.web/src/components/ProductImageGallery.tsx b/src/food-market.web/src/components/ProductImageGallery.tsx index e5cd8d5..99d7118 100644 --- a/src/food-market.web/src/components/ProductImageGallery.tsx +++ b/src/food-market.web/src/components/ProductImageGallery.tsx @@ -70,7 +70,7 @@ export function ProductImageGallery({ productId }: Props) { - JPG/PNG/WEBP/GIF, до 10 МБ + JPG/PNG/WEBP/GIF, до 10 МБ {images.length === 0 ? ( diff --git a/src/food-market.web/src/components/ProductPicker.tsx b/src/food-market.web/src/components/ProductPicker.tsx index f12de0e..2be880e 100644 --- a/src/food-market.web/src/components/ProductPicker.tsx +++ b/src/food-market.web/src/components/ProductPicker.tsx @@ -75,7 +75,7 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то {p.referencePrice !== null && ( -
+
закуп: {p.referencePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''}
)} diff --git a/src/food-market.web/src/components/ShortcutsOverlay.tsx b/src/food-market.web/src/components/ShortcutsOverlay.tsx index 21a77b7..240328a 100644 --- a/src/food-market.web/src/components/ShortcutsOverlay.tsx +++ b/src/food-market.web/src/components/ShortcutsOverlay.tsx @@ -40,7 +40,7 @@ export function ShortcutsOverlay() { >
- +

Горячие клавиши

) diff --git a/src/food-market.web/src/pages/AbcReportPage.tsx b/src/food-market.web/src/pages/AbcReportPage.tsx index fb05d37..dcddf02 100644 --- a/src/food-market.web/src/pages/AbcReportPage.tsx +++ b/src/food-market.web/src/pages/AbcReportPage.tsx @@ -87,7 +87,7 @@ export function AbcReportPage() {

Отчёт «ABC-анализ»

-

+

Топ-товары по выбранной метрике с разбиением A/B/C по правилу Парето (80/15/5).

@@ -140,7 +140,7 @@ export function AbcReportPage() {
- {rep.isLoading &&
Загружаю…
} + {rep.isLoading &&
Загружаю…
} {!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( - Ранг - Класс - Товар - Артикул - Метрика - Доля,% - Накопит. + Ранг + Класс + Товар + Артикул + Метрика + Доля,% + Накопит. {rep.data.map((r) => ( - {r.rank} + {r.rank} - {r.abcClass} + {r.abcClass} {r.productName} - {r.productArticle ?? '—'} + {r.productArticle ?? '—'} {r.metricValue.toLocaleString('ru', moneyFmt)} - {r.share.toFixed(2)} + {r.share.toFixed(2)}
@@ -181,7 +181,7 @@ export function AbcReportPage() { style={{ width: `${Math.min(100, r.cumulativeShare)}%`, height: '100%' }} />
- {r.cumulativeShare.toFixed(1)}% + {r.cumulativeShare.toFixed(1)}%
diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index b5a943e..1d3bb52 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -48,7 +48,7 @@ function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
-
{label}
+
{label}
{value}
@@ -75,7 +75,7 @@ function MiniCard({ icon: Icon, label, value, isLoading }: { return (
- {label} + {label}
@@ -144,7 +144,7 @@ export function DashboardPage() { title={t('dashboard.title')} description={me.data ? t('dashboard.welcome', { name: me.data.name }) : t('dashboard.fallbackDescription')} actions={( - + {isConnected ? : } @@ -187,7 +187,7 @@ export function DashboardPage() {

{t('dashboard.chartTitle')}

-

{t('dashboard.chartSubtitle')}

+

{t('dashboard.chartSubtitle')}

{stats.isLoading ? ( diff --git a/src/food-market.web/src/pages/DemandEditPage.tsx b/src/food-market.web/src/pages/DemandEditPage.tsx index a40b639..5340b98 100644 --- a/src/food-market.web/src/pages/DemandEditPage.tsx +++ b/src/food-market.web/src/pages/DemandEditPage.tsx @@ -239,7 +239,7 @@ export function DemandEditPage() { {isNew ? 'Новая отгрузка' : existing.data?.number ?? 'Отгрузка'} {isPosted && ( -

+

Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

)} @@ -360,20 +360,20 @@ export function DemandEditPage() {
{form.lines.length === 0 ? ( -
Нет позиций.
+
Нет позиций.
) : (
- - - - - - - - + + + + + + + + @@ -382,10 +382,10 @@ export function DemandEditPage() { - - +
ТоварЕд.ОстатокКол-воЦенаСкидкаНДССуммаТоварЕд.ОстатокКол-воЦенаСкидкаНДССумма
{l.productName || '(без названия)'}
- {l.productArticle &&
{l.productArticle}
} + {l.productArticle &&
{l.productArticle}
}
{l.unitSymbol ?? '—'} + {l.unitSymbol ?? '—'} {l.stockAtStore != null ? l.stockAtStore.toLocaleString('ru') : '—'} diff --git a/src/food-market.web/src/pages/EmployeeRolesPage.tsx b/src/food-market.web/src/pages/EmployeeRolesPage.tsx index f7b41ec..bb1b22f 100644 --- a/src/food-market.web/src/pages/EmployeeRolesPage.tsx +++ b/src/food-market.web/src/pages/EmployeeRolesPage.tsx @@ -179,7 +179,7 @@ export function EmployeeRolesPage() { )}, { header: 'Тип', width: '140px', cell: (r) => r.isSystem ? Системная - : Кастомная }, + : Кастомная }, { header: 'Прав активно', width: '160px', className: 'text-right', cell: (r) => { const count = Object.values(r.permissions ?? {}).filter(Boolean).length return {count} / {Object.keys(r.permissions ?? {}).length} @@ -211,17 +211,17 @@ export function EmployeeRolesPage() { } >
-

+

Стартовый набор прав. После создания роли сможешь дополнительно настроить каждый пункт через матрицу прав.

{data?.items.map((r) => ( ))} @@ -293,7 +293,7 @@ export function EmployeeRolesPage() {

Права

{PERM_GROUPS.map((g) => (
-
{g.title}
+
{g.title}
{g.perms.map((p) => ( )} {form?.id && activeEmployee?.status === 'deleted' && ( - Сотрудник удалён — изменения недоступны. + Сотрудник удалён — изменения недоступны. )} @@ -396,7 +396,7 @@ export function EmployeesPage() { {r.name} {r.isSystem && системная} - {r.description &&
{r.description}
} + {r.description &&
{r.description}
}
))} @@ -421,7 +421,7 @@ export function EmployeesPage() { /> ))}
-

Если ничего не выбрано — кассир работает на всех кассах.

+

Если ничего не выбрано — кассир работает на всех кассах.

)} setForm({ ...form, isActive: v })} /> {activeEmployee?.isOwner && ( -

+

Главного администратора нельзя деактивировать в обычной админке. Это действие выполняет Супер-администратор платформы.

diff --git a/src/food-market.web/src/pages/EnterEditPage.tsx b/src/food-market.web/src/pages/EnterEditPage.tsx index 7d1f722..f5e1f85 100644 --- a/src/food-market.web/src/pages/EnterEditPage.tsx +++ b/src/food-market.web/src/pages/EnterEditPage.tsx @@ -219,7 +219,7 @@ export function EnterEditPage() { {isNew ? 'Новое оприходование' : existing.data?.number ?? 'Оприходование'} {isPosted && ( -

+

Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

)} @@ -319,17 +319,17 @@ export function EnterEditPage() {
{form.lines.length === 0 ? ( -
Нет позиций. Добавь товар из справочника.
+
Нет позиций. Добавь товар из справочника.
) : (
- - - - - + + + + + @@ -338,9 +338,9 @@ export function EnterEditPage() { - + - - - - - + + + + + @@ -161,9 +161,9 @@ export function SalesReportPage() { - - - + + + ))} diff --git a/src/food-market.web/src/pages/StockReportPage.tsx b/src/food-market.web/src/pages/StockReportPage.tsx index 0ef6be4..8897b7b 100644 --- a/src/food-market.web/src/pages/StockReportPage.tsx +++ b/src/food-market.web/src/pages/StockReportPage.tsx @@ -64,7 +64,7 @@ export function StockReportPage() {

Отчёт «Остатки на дату»

-

+

Реконструкция через журнал движений. Стоимость — последний UnitCost движения; если в журнале нет — Product.Cost (приближённая оценка).

@@ -113,7 +113,7 @@ export function StockReportPage() {
- {rep.isLoading &&
Загружаю…
} + {rep.isLoading &&
Загружаю…
} {!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
- - - - - - - + + + + + + + {rep.data.map((r) => ( - - - + + + - + ))} diff --git a/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx index defbae4..42b48ac 100644 --- a/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx @@ -49,7 +49,7 @@ function Kpi({ icon: Icon, label, value, hint, accent = 'indigo', muted = false
-
{label}
+
{label}
{value}
{hint &&
{hint}
}
@@ -169,7 +169,7 @@ export function SuperAdminDashboardPage() { {fmt.format(o.productCount)} товаров -
+
{fmt.format(o.employeeCount)} сотр. {o.lastLoginAt && <> · last login {new Date(o.lastLoginAt).toLocaleDateString('ru')}}
@@ -197,12 +197,12 @@ export function SuperAdminDashboardPage() { {audit.data?.slice(0, 6).map((r) => (
  • - {r.actionType} + {r.actionType} {new Date(r.createdAt).toLocaleString('ru', { dateStyle: 'short', timeStyle: 'short' })}
    {r.organizationName && «{r.organizationName}»} - {r.description} + {r.description}
  • ))} diff --git a/src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx b/src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx index 1712379..0464c6d 100644 --- a/src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminOrgEmployeesPage.tsx @@ -231,7 +231,7 @@ export function SuperAdminOrgEmployeesPage() {
    @@ -289,7 +289,7 @@ export function SuperAdminOrgEmployeesPage() {
    ТоварЕд.Кол-воЦена ед.СуммаТоварЕд.Кол-воЦена ед.Сумма
    {l.productName || '(без названия)'}
    - {l.productArticle &&
    {l.productArticle}
    } + {l.productArticle &&
    {l.productArticle}
    }
    {l.unitSymbol ?? '—'}{l.unitSymbol ?? '—'} updateLine(i, { quantity: v ?? 0 })} /> diff --git a/src/food-market.web/src/pages/InventoryEditPage.tsx b/src/food-market.web/src/pages/InventoryEditPage.tsx index e252956..f225200 100644 --- a/src/food-market.web/src/pages/InventoryEditPage.tsx +++ b/src/food-market.web/src/pages/InventoryEditPage.tsx @@ -240,7 +240,7 @@ export function InventoryEditPage() { {isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'} {isPosted && ( -

    +

    Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

    )} @@ -305,7 +305,7 @@ export function InventoryEditPage() {
    Излишек: +{surplusValue.toLocaleString('ru')} Недостача: {shortageValue.toLocaleString('ru')} - Итого расхождений: {(surplusValue + shortageValue).toLocaleString('ru')} + Итого расхождений: {(surplusValue + shortageValue).toLocaleString('ru')}
    {!isNew && ( @@ -340,19 +340,19 @@ export function InventoryEditPage() {

    Позиции

    {form.lines.length === 0 ? ( -
    Нет товаров на складе на момент создания документа.
    +
    Нет товаров на складе на момент создания документа.
    ) : (
    - - - - - - - + + + + + + + @@ -360,19 +360,19 @@ export function InventoryEditPage() { - - + + - - - + diff --git a/src/food-market.web/src/pages/LossEditPage.tsx b/src/food-market.web/src/pages/LossEditPage.tsx index e671331..35f17c9 100644 --- a/src/food-market.web/src/pages/LossEditPage.tsx +++ b/src/food-market.web/src/pages/LossEditPage.tsx @@ -222,7 +222,7 @@ export function LossEditPage() { {isNew ? 'Новое списание' : existing.data?.number ?? 'Списание'} {isPosted && ( -

    +

    Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

    )} @@ -330,18 +330,18 @@ export function LossEditPage() { {form.lines.length === 0 ? ( -
    Нет позиций.
    +
    Нет позиций.
    ) : (
    ТоварЕд.УчётФактРасхожд.Цена ед.СуммаТоварЕд.УчётФактРасхожд.Цена ед.Сумма
    {l.productName || '(без названия)'}
    - {l.productArticle &&
    {l.productArticle}
    } + {l.productArticle &&
    {l.productArticle}
    }
    {l.unitSymbol ?? '—'}{l.bookQty.toLocaleString('ru')}{l.unitSymbol ?? '—'}{l.bookQty.toLocaleString('ru')} updateLine(i, v ?? 0)} /> 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}> + 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500 dark:text-slate-400'}`}> {l.diff === 0 ? '—' : `${l.diff > 0 ? '+' : ''}${l.diff.toLocaleString('ru')}`} {l.unitCost.toLocaleString('ru')} 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}> + {l.unitCost.toLocaleString('ru')} 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500 dark:text-slate-400'}`}> {l.diff === 0 ? '—' : (l.diff * l.unitCost).toLocaleString('ru')}
    - - - - - - + + + + + + @@ -350,10 +350,10 @@ export function LossEditPage() { - - + - - - - - - + + + + + + @@ -164,10 +164,10 @@ export function ProfitReportPage() { - + - + ))} diff --git a/src/food-market.web/src/pages/PromotionsPage.tsx b/src/food-market.web/src/pages/PromotionsPage.tsx index 3911acb..0303622 100644 --- a/src/food-market.web/src/pages/PromotionsPage.tsx +++ b/src/food-market.web/src/pages/PromotionsPage.tsx @@ -139,7 +139,7 @@ export function PromotionsPage() { { header: 'Мин. чек', width: '110px', className: 'text-right font-mono', cell: (r) => r.minSaleAmount > 0 ? `${r.minSaleAmount.toLocaleString('ru')} ₸` : '—' }, { header: 'Статус', width: '110px', cell: (r) => r.isActive ? Активна - : Выключена }, + : Выключена }, ]} /> )} diff --git a/src/food-market.web/src/pages/RetailSaleEditPage.tsx b/src/food-market.web/src/pages/RetailSaleEditPage.tsx index d0bc106..548fac9 100644 --- a/src/food-market.web/src/pages/RetailSaleEditPage.tsx +++ b/src/food-market.web/src/pages/RetailSaleEditPage.tsx @@ -238,7 +238,7 @@ export function RetailSaleEditPage() { {isNew ? 'Новый чек' : existing.data?.number ?? 'Чек'} {isPosted && ( -

    +

    Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

    )} @@ -378,12 +378,12 @@ export function RetailSaleEditPage() {
    ТоварЕд.ОстатокСписатьЦена ед.СуммаТоварЕд.ОстатокСписатьЦена ед.Сумма
    {l.productName || '(без названия)'}
    - {l.productArticle &&
    {l.productArticle}
    } + {l.productArticle &&
    {l.productArticle}
    }
    {l.unitSymbol ?? '—'} + {l.unitSymbol ?? '—'} {l.stockAtStore != null ? l.stockAtStore.toLocaleString('ru') : '—'} diff --git a/src/food-market.web/src/pages/LoyaltyProgramsPage.tsx b/src/food-market.web/src/pages/LoyaltyProgramsPage.tsx index 7890fe8..2630246 100644 --- a/src/food-market.web/src/pages/LoyaltyProgramsPage.tsx +++ b/src/food-market.web/src/pages/LoyaltyProgramsPage.tsx @@ -119,7 +119,7 @@ export function LoyaltyProgramsPage() { { header: 'Карт', width: '90px', className: 'text-right', cell: (r) => r.cardsCount }, { header: 'Статус', width: '110px', cell: (r) => r.isActive ? Активна - : Выключена }, + : Выключена }, ]} /> )} diff --git a/src/food-market.web/src/pages/MoySkladImportPage.tsx b/src/food-market.web/src/pages/MoySkladImportPage.tsx index b2777f3..7f2772e 100644 --- a/src/food-market.web/src/pages/MoySkladImportPage.tsx +++ b/src/food-market.web/src/pages/MoySkladImportPage.tsx @@ -142,7 +142,7 @@ export function MoySkladImportPage() { {test.data && (
    Подключено: {test.data.organization} - {test.data.inn && (идентификатор {test.data.inn})} + {test.data.inn && (идентификатор {test.data.inn})}
    )} {test.error &&
    {formatError(test.error)}
    } @@ -222,7 +222,7 @@ function Stat({ label, value, accent }: { label: string; value: number; accent?: const fg = accent === 'green' ? 'text-emerald-700 dark:text-emerald-400' : '' return (
    -
    {label}
    +
    {label}
    {value.toLocaleString('ru')}
    ) @@ -300,9 +300,9 @@ function DangerZone() { {wipeJob.data.status === 'Failed' && } {wipeJob.data.status === 'Running' && } {wipeJob.data.stage ?? wipeJob.data.status} - удалено записей: {wipeJob.data.deleted.toLocaleString('ru')} + удалено записей: {wipeJob.data.deleted.toLocaleString('ru')} - {wipeJob.data.message &&
    {wipeJob.data.message}
    } + {wipeJob.data.message &&
    {wipeJob.data.message}
    } )} @@ -312,7 +312,7 @@ function DangerZone() { function Tile({ label, value }: { label: string; value: number }) { return (
    -
    {label}
    +
    {label}
    {value.toLocaleString('ru')}
    ) diff --git a/src/food-market.web/src/pages/OrgAuditLogPage.tsx b/src/food-market.web/src/pages/OrgAuditLogPage.tsx index c08736e..dbf170b 100644 --- a/src/food-market.web/src/pages/OrgAuditLogPage.tsx +++ b/src/food-market.web/src/pages/OrgAuditLogPage.tsx @@ -104,11 +104,11 @@ export function OrgAuditLogPage() { }`}>{r.action}
    )}, { header: 'EntityId', width: '110px', cell: (r) => ( - r.entityId ? {r.entityId.slice(0, 8)} : '—' + r.entityId ? {r.entityId.slice(0, 8)} : '—' )}, { header: 'Изменения', cell: (r) => (
    - показать diff + показать diff
    {
                     (() => {
                       try { return JSON.stringify(JSON.parse(r.changesJson), null, 2) }
    diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx
    index b504f78..7a1f3e7 100644
    --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx
    +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx
    @@ -100,7 +100,7 @@ export function OrganizationSettingsPage() {
                 checked={form.multiCurrencyEnabled}
                 onChange={(v) => setForm({ ...form, multiCurrencyEnabled: v })}
               />
    -          

    +

    Если выключено — в формах продаж и закупок выбор валюты не показывается, всё считается в валюте по умолчанию.

    @@ -110,7 +110,7 @@ export function OrganizationSettingsPage() {
    -

    +

    Валюта и ставка НДС берутся из страны ({form.countryCode}) — чтобы изменить — обратитесь к администратору платформы (справочник стран управляется в системной консоли).

    @@ -120,7 +120,7 @@ export function OrganizationSettingsPage() { checked={form.showVatEnabledOnProduct} onChange={(v) => setForm({ ...form, showVatEnabledOnProduct: v })} /> -

    +

    Если выключено — поля «НДС %» и «В том числе НДС» на карточке товара скрыты, все новые товары получают ставку из страны организации. Если включено — у каждого товара можно задать ставку вручную (хлеб = 0%, лекарства = 0% и т.п.). @@ -131,7 +131,7 @@ export function OrganizationSettingsPage() { checked={form.showServiceOnProduct} onChange={(v) => setForm({ ...form, showServiceOnProduct: v })} /> -

    +

    Нужно, если помимо физических товаров продаются услуги (доставка, сборка и т.п.). По умолчанию галка скрыта.

    @@ -141,7 +141,7 @@ export function OrganizationSettingsPage() { checked={form.showMarkedOnProduct} onChange={(v) => setForm({ ...form, showMarkedOnProduct: v })} /> -

    +

    Включать, только если продаётся маркируемая категория (алкоголь, табак, лекарства). По умолчанию галка скрыта.

    @@ -151,7 +151,7 @@ export function OrganizationSettingsPage() { checked={form.showMinMaxStock} onChange={(v) => setForm({ ...form, showMinMaxStock: v })} /> -

    +

    Если включено — на карточке товара есть поля «Минимальный / Максимальный остаток» для автозаказа. По умолчанию скрыто.

    @@ -161,7 +161,7 @@ export function OrganizationSettingsPage() { checked={form.allowFractionalPrices} onChange={(v) => setForm({ ...form, allowFractionalPrices: v })} /> -

    +

    Включи если хочешь использовать цены с двумя знаками после запятой (1500.50 ₸). По умолчанию — целые тенге, без копеек.

    @@ -171,7 +171,7 @@ export function OrganizationSettingsPage() { checked={form.showReferencePriceOnProduct} onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })} /> -

    +

    Справочная цена закупа — необязательное поле. Авто-заполняется первой проведённой приёмкой и через 30 дней без приёмок переписывается на текущую себестоимость.

    @@ -181,7 +181,7 @@ export function OrganizationSettingsPage() { checked={form.showCountryOfOriginOnProduct} onChange={(v) => setForm({ ...form, showCountryOfOriginOnProduct: v })} /> -

    +

    По умолчанию выключено. Включай если торгуешь импортом или ведёшь учёт по странам — тогда в карточке товара будет селект «Страна происхождения».

    @@ -191,7 +191,7 @@ export function OrganizationSettingsPage() { checked={form.showDescriptionOnProduct} onChange={(v) => setForm({ ...form, showDescriptionOnProduct: v })} /> -

    +

    По умолчанию выключено — описания захламляют карточку. Включай если ведёшь подробные тексты на товарах.

    @@ -254,9 +254,9 @@ function TelegramSection() { const bound = !!s?.chatId return ( -
    +

    📨 Telegram владельца

    -

    +

    Ежедневная сводка (выручка вчера, топ-3 товара, low-stock) в 09:00 МСК.

    @@ -328,12 +328,12 @@ function DemoSeedSection() { const seeded = !!s?.alreadySeeded return ( -
    +

    Демо-данные

    -

    +

    Заполняет организацию реалистичными данными для демонстрации стейджа: 50 товаров в 5 группах, 10 контрагентов, 5 приёмок, 30 продаж, 1 опт-отгрузка, 1 списание, 1 перемещение, 1 инвентаризация. Идемпотентно — повторный @@ -341,11 +341,11 @@ function DemoSeedSection() {

    {status.isLoading && ( -

    Загружаю статус…

    +

    Загружаю статус…

    )} {s && ( -
    +
    Товаров: {s.products}
    Групп: {s.groups}
    Контрагентов: {s.counterparties}
    diff --git a/src/food-market.web/src/pages/PriceTypesPage.tsx b/src/food-market.web/src/pages/PriceTypesPage.tsx index 4e2c05e..b175421 100644 --- a/src/food-market.web/src/pages/PriceTypesPage.tsx +++ b/src/food-market.web/src/pages/PriceTypesPage.tsx @@ -116,7 +116,7 @@ export function PriceTypesPage() { {form && (
    {form.isSystem && ( -

    +

    Системная запись. Имя можно переименовать; флаг «Обязательно» зафиксирован, удаление запрещено.

    )} diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 84baef7..f85863f 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -305,7 +305,7 @@ export function ProductEditPage() {
    -

    Штрихкоды

    +

    Штрихкоды

    @@ -401,7 +401,7 @@ export function ProductEditPage() { >
    -

    Закупка

    +

    Закупка

    {org.data?.showReferencePriceOnProduct && ( @@ -436,7 +436,7 @@ export function ProductEditPage() {
    -

    Цены продажи

    +

    Цены продажи

    {/* Список цен рендерится по справочнику PriceType: одно поле на каждый * тип, без выпадашки выбора. Значение хранится в form.prices, * key = priceTypeId. Для отсутствующих записей при наборе создаётся @@ -605,7 +605,7 @@ function AdvancedSection({ children }: { children: ReactNode }) { className="w-full flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30 text-left hover:bg-slate-100/60 dark:hover:bg-slate-800/50" >

    Расширенные параметры

    - {open ? 'свернуть' : 'развернуть'} + {open ? 'свернуть' : 'развернуть'} {open &&
    {children}
    }
    diff --git a/src/food-market.web/src/pages/ProductGroupsPage.tsx b/src/food-market.web/src/pages/ProductGroupsPage.tsx index f9fe108..5e0223c 100644 --- a/src/food-market.web/src/pages/ProductGroupsPage.tsx +++ b/src/food-market.web/src/pages/ProductGroupsPage.tsx @@ -82,7 +82,7 @@ export function ProductGroupsPage() { )} )}, - { header: 'Путь', sortKey: 'path', cell: (r) => {r.path} }, + { header: 'Путь', sortKey: 'path', cell: (r) => {r.path} }, { header: 'Наценка', width: '140px', cell: (r) => (
    e.stopPropagation()}> setForm({ ...form, markupPercent: n })} placeholder="нет автонаценки" /> -

    +

    При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉. Пусто — автонаценка отключена.

    diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index a117aeb..024bf83 100644 --- a/src/food-market.web/src/pages/ProductsPage.tsx +++ b/src/food-market.web/src/pages/ProductsPage.tsx @@ -74,7 +74,7 @@ function Tri({ ] return (
    - {label} + {label}
    {opts.map((o) => ( +
    {groupsTree}
    @@ -216,7 +216,7 @@ export function ProductsPage() {

    Товары

    -

    +

    {data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}

    @@ -241,7 +241,7 @@ export function ProductsPage() { { setFilters({ ...filters, isService: v }); setPage(1) }} /> )}
    - Фасовка + Фасовка
    ГруппаВыручкаСебест.ПрибыльМаржа,%Кол-воГруппаВыручкаСебест.ПрибыльМаржа,%Кол-во
    {r.label} {r.revenue.toLocaleString('ru', moneyFmt)}{r.cost.toLocaleString('ru', moneyFmt)}{r.cost.toLocaleString('ru', moneyFmt)} = 0 ? 'text-green-700' : 'text-red-700'}`}>{r.profit.toLocaleString('ru', moneyFmt)} {r.marginPercent.toFixed(2)}{r.quantity.toLocaleString('ru')}{r.quantity.toLocaleString('ru')}
    - - - - - - + + + + + + @@ -394,7 +394,7 @@ export function RetailSaleEditPage() {
    {l.productName}
    {l.productArticle &&
    {l.productArticle}
    } - + - - + + - + @@ -442,7 +442,7 @@ export function RetailSaleEditPage() {
    ТоварЕд.Кол-воЦенаСкидкаСуммаТоварЕд.Кол-воЦенаСкидкаСумма
    {l.unitName}{l.unitName}
    Подытог:
    Подытог: {subtotal.toLocaleString('ru', { maximumFractionDigits: 2 })}
    Скидка:
    Скидка: −{discountTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}
    {grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}{' '} - {currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''} + {currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}
    diff --git a/src/food-market.web/src/pages/SalesReportPage.tsx b/src/food-market.web/src/pages/SalesReportPage.tsx index c871051..6c69ca9 100644 --- a/src/food-market.web/src/pages/SalesReportPage.tsx +++ b/src/food-market.web/src/pages/SalesReportPage.tsx @@ -83,7 +83,7 @@ export function SalesReportPage() {

    Отчёт «Продажи»

    -

    +

    Проведённые чеки за период. Возвраты включаются с минусом (netto).

    @@ -136,7 +136,7 @@ export function SalesReportPage() {
    - {rep.isLoading &&
    Загружаю…
    } + {rep.isLoading &&
    Загружаю…
    } {!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
    ГруппаВыручкаСкидкиЧековКол-воГруппаВыручкаСкидкиЧековКол-во
    {r.label} {r.revenue.toLocaleString('ru', moneyFmt)}{r.discount === 0 ? '—' : r.discount.toLocaleString('ru', moneyFmt)}{r.transactions}{r.quantity.toLocaleString('ru')}{r.discount === 0 ? '—' : r.discount.toLocaleString('ru', moneyFmt)}{r.transactions}{r.quantity.toLocaleString('ru')}
    ТоварАртикулЕд.СкладКол-воЦенаСтоимостьТоварАртикулЕд.СкладКол-воЦенаСтоимость
    {r.productName}{r.productArticle ?? '—'}{r.unitName ?? '—'}{r.storeName}{r.productArticle ?? '—'}{r.unitName ?? '—'}{r.storeName} {r.quantity.toLocaleString('ru')}{r.cost.toLocaleString('ru', moneyFmt)}{r.cost.toLocaleString('ru', moneyFmt)} {r.value.toLocaleString('ru', moneyFmt)}
    - - - - - - + + + + + + @@ -354,10 +354,10 @@ export function SupplierReturnEditPage() { - - +
    ТоварЕд.ОстатокВозвращ.ЦенаСуммаТоварЕд.ОстатокВозвращ.ЦенаСумма
    {l.productName || '(без названия)'}
    - {l.productArticle &&
    {l.productArticle}
    } + {l.productArticle &&
    {l.productArticle}
    }
    {l.unitSymbol ?? '—'} + {l.unitSymbol ?? '—'} {l.stockAtStore != null ? l.stockAtStore.toLocaleString('ru') : '—'} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index d26c004..981a01c 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -300,7 +300,7 @@ export function SupplyEditPage() { {isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'} {isPosted && ( -

    +

    Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

    )} @@ -407,7 +407,7 @@ export function SupplyEditPage() { } }} /> -

    +

    Только проведённый документ влияет на остатки склада и себестоимость. Черновик можно править, проведённый — только распровести и редактировать заново. {isPosted && existing.data?.postedAt && ( @@ -433,12 +433,12 @@ export function SupplyEditPage() { - - - - - - + + + + + + @@ -455,7 +455,7 @@ export function SupplyEditPage() { )} - + diff --git a/src/food-market.web/src/pages/TransferEditPage.tsx b/src/food-market.web/src/pages/TransferEditPage.tsx index ac3f390..b7d806a 100644 --- a/src/food-market.web/src/pages/TransferEditPage.tsx +++ b/src/food-market.web/src/pages/TransferEditPage.tsx @@ -207,7 +207,7 @@ export function TransferEditPage() { {isNew ? 'Новое перемещение' : existing.data?.number ?? 'Перемещение'} {isPosted && ( -

    +

    Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

    )} @@ -312,18 +312,18 @@ export function TransferEditPage() { {form.lines.length === 0 ? ( -
    Нет позиций.
    +
    Нет позиций.
    ) : (
    ТоварЕд.КоличествоЦена{systemPriceTypeName} (карточка)СуммаТоварЕд.КоличествоЦена{systemPriceTypeName} (карточка)Сумма
    {l.unitName}{l.unitName} {grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })} {' '} - + {currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}
    - - - - - - + + + + + + @@ -332,10 +332,10 @@ export function TransferEditPage() { - - +
    ТоварЕд.На отправителеКол-воЦена ед.СуммаТоварЕд.На отправителеКол-воЦена ед.Сумма
    {l.productName || '(без названия)'}
    - {l.productArticle &&
    {l.productArticle}
    } + {l.productArticle &&
    {l.productArticle}
    }
    {l.unitSymbol ?? '—'} + {l.unitSymbol ?? '—'} {l.stockAtFrom != null ? l.stockAtFrom.toLocaleString('ru') : '—'} diff --git a/src/food-market.web/src/pages/TransfersPage.tsx b/src/food-market.web/src/pages/TransfersPage.tsx index 99cef10..39b0125 100644 --- a/src/food-market.web/src/pages/TransfersPage.tsx +++ b/src/food-market.web/src/pages/TransfersPage.tsx @@ -64,15 +64,15 @@ export function TransfersPage() { onSortChange={setSort} onRowClick={(r) => navigate(`/inventory/transfers/${r.id}`)} columns={[ - { header: '№', width: '180px', sortKey: 'number', cell: (r) => {r.number} }, + { header: '№', width: '180px', sortKey: 'number', cell: (r) => {r.number} }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( r.status === TransferStatus.Posted ? Проведён - : Черновик + : Черновик )}, { header: 'Откуда → Куда', cell: (r) => ( - + {r.fromStoreName} {r.toStoreName} diff --git a/tests/e2e/scenarios/stage-ui-s10-dark-audit.spec.ts b/tests/e2e/scenarios/stage-ui-s10-dark-audit.spec.ts new file mode 100644 index 0000000..48cc541 --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-s10-dark-audit.spec.ts @@ -0,0 +1,80 @@ +/** + * S10-4: аудит dark mode. Делает скриншоты топ-10 страниц в светлой и + * тёмной теме на одном signup-сидед (год-демо). Перед-после фиксируются + * в reports/dark-mode/. + * + * Что проверяется визуально: + * - текст не растворяется в фон (text-slate-500 → dark:text-slate-400 etc). + * - белые карточки в dark получают slate-900 фон. + * - бордеры остаются заметными. + * + * Этот spec не делает assert'ов — это инструмент для ручного аудита + * (screenshot-diff'ы). Считается passed если страница рендерится без + * console-errors в обоих режимах. + */ +import { test, expect, type Page } from '@playwright/test' +import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { request as apiRequest } from '@playwright/test' + +const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz' + +const PAGES = [ + { name: '01-dashboard', path: '/dashboard' }, + { name: '02-products', path: '/catalog/products' }, + { name: '03-counterparties', path: '/catalog/counterparties' }, + { name: '04-stock', path: '/inventory/stock' }, + { name: '05-supplies', path: '/purchases/supplies' }, + { name: '06-retail-sales', path: '/sales/retail' }, + { name: '07-report-sales', path: '/reports/sales' }, + { name: '08-report-stock', path: '/reports/stock' }, + { name: '09-report-profit', path: '/reports/profit' }, + { name: '10-report-abc', path: '/reports/abc' }, +] + +test.describe('S10-4 dark-mode audit', () => { + test('snapshot 10 страниц light + dark', async ({ page, browser }) => { + test.setTimeout(180_000) + const dir = resolve('reports/dark-mode') + mkdirSync(dir, { recursive: true }) + + // 1. Однажды поднимаем org с богатыми данными (year-seed). + const sess = await apiSignup('s10dark') + const ctx = await apiRequest.newContext({ + baseURL: BASE, ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` }, + timeout: 60_000, // year-seed ~16-20s, 15s default не хватает. + }) + await ctx.post('/api/admin/seed-demo?years=1', { timeout: 60_000 }) + await ctx.dispose() + + // 2. Light mode проход — текущий page (default ru-RU + light). + for (const p of PAGES) { + await attachSession(page, sess, p.path) + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}) + await page.screenshot({ path: `${dir}/${p.name}-light.png`, fullPage: false }) + } + + // 3. Dark mode проход — новый context с prefers-color-scheme: dark. + const darkCtx = await browser.newContext({ + baseURL: BASE, ignoreHTTPSErrors: true, + locale: 'ru-RU', colorScheme: 'dark', + viewport: { width: 1280, height: 800 }, + }) + const darkPage = await darkCtx.newPage() + const errs = watchPage(darkPage) + + for (const p of PAGES) { + await attachSession(darkPage, sess, p.path) + await darkPage.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}) + await darkPage.screenshot({ path: `${dir}/${p.name}-dark.png`, fullPage: false }) + } + + expectNoErrors(errs, 'dark mode pages') + await darkCtx.close() + + // Sanity: главная в dark должна иметь нашу dashbg. + expect(await page.evaluate(() => document.body.tagName.toLowerCase())).toBe('body') + }) +})