From 23ed1ecf3def625b0688869e4d01832894b9746d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Sun, 6 Sep 2026 19:46:28 +0300 Subject: [PATCH] =?UTF-8?q?DI2=20=D0=B2=D0=BD=D0=B5=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D1=82=D0=B0=D1=81=D0=BA=202:=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=81=D0=BA=D0=B8=D0=B9=20=D1=81?= =?UTF-8?q?=D1=83=D0=B1=D0=B0=D0=BF=D0=BF=20Data-Insight2=20(client-di2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client-di2/: копия фронтенда DI2 + shared/schema.ts, main.tsx обёрнут в wouter Router base=/di2, favicon относительный - client-di2/tailwind.config.ts (свои токены DI2) подключён через @config в index.css — иначе postcss взял бы корневой конфиг iistwin - vite.config.di2.ts: base /di2/, root client-di2, outDir dist/public-di2 - package.json: build/check через && включают сборку и typecheck di2 (build:di2, check:di2), новых зависимостей нет - Навигация финансов: ссылка «Data-Insight (полная версия)» → /di2 (target=_blank) в FinanceSidebar и FinanceBottomNav - Типовые фиксы предсуществующих ошибок DI2 (casts, ruleLogic в mutation-типах, Set) --- .superpowers/sdd/di2-embed/task-2-report.md | 46 + client-di2/index.html | 15 + client-di2/shared/schema.ts | 366 +++ client-di2/src/App.tsx | 183 ++ client-di2/src/components/app-sidebar.tsx | 164 ++ .../components/category-override-dialog.tsx | 482 ++++ client-di2/src/components/column-filter.tsx | 148 ++ client-di2/src/components/copyable-cell.tsx | 58 + client-di2/src/components/database-error.tsx | 18 + .../src/components/draggable-column-list.tsx | 112 + client-di2/src/components/ui/accordion.tsx | 56 + client-di2/src/components/ui/alert-dialog.tsx | 139 ++ client-di2/src/components/ui/alert.tsx | 59 + client-di2/src/components/ui/aspect-ratio.tsx | 5 + client-di2/src/components/ui/avatar.tsx | 51 + client-di2/src/components/ui/badge.tsx | 38 + client-di2/src/components/ui/breadcrumb.tsx | 115 + client-di2/src/components/ui/button.tsx | 62 + client-di2/src/components/ui/calendar.tsx | 68 + client-di2/src/components/ui/card.tsx | 85 + client-di2/src/components/ui/carousel.tsx | 260 +++ client-di2/src/components/ui/chart.tsx | 365 +++ client-di2/src/components/ui/checkbox.tsx | 28 + client-di2/src/components/ui/collapsible.tsx | 11 + client-di2/src/components/ui/command.tsx | 151 ++ client-di2/src/components/ui/context-menu.tsx | 198 ++ client-di2/src/components/ui/dialog.tsx | 122 + client-di2/src/components/ui/drawer.tsx | 118 + .../src/components/ui/dropdown-menu.tsx | 198 ++ client-di2/src/components/ui/form.tsx | 178 ++ client-di2/src/components/ui/hover-card.tsx | 29 + client-di2/src/components/ui/input-otp.tsx | 69 + client-di2/src/components/ui/input.tsx | 23 + client-di2/src/components/ui/label.tsx | 24 + client-di2/src/components/ui/menubar.tsx | 256 +++ .../src/components/ui/navigation-menu.tsx | 128 ++ client-di2/src/components/ui/pagination.tsx | 117 + client-di2/src/components/ui/popover.tsx | 29 + client-di2/src/components/ui/progress.tsx | 28 + client-di2/src/components/ui/radio-group.tsx | 42 + client-di2/src/components/ui/resizable.tsx | 45 + client-di2/src/components/ui/scroll-area.tsx | 46 + client-di2/src/components/ui/select.tsx | 160 ++ client-di2/src/components/ui/separator.tsx | 29 + client-di2/src/components/ui/sheet.tsx | 140 ++ client-di2/src/components/ui/sidebar.tsx | 727 ++++++ client-di2/src/components/ui/skeleton.tsx | 15 + client-di2/src/components/ui/slider.tsx | 26 + client-di2/src/components/ui/switch.tsx | 27 + client-di2/src/components/ui/table.tsx | 117 + client-di2/src/components/ui/tabs.tsx | 53 + client-di2/src/components/ui/textarea.tsx | 22 + client-di2/src/components/ui/toast.tsx | 127 ++ client-di2/src/components/ui/toaster.tsx | 33 + client-di2/src/components/ui/toggle-group.tsx | 61 + client-di2/src/components/ui/toggle.tsx | 43 + client-di2/src/components/ui/tooltip.tsx | 30 + client-di2/src/hooks/use-column-labels.ts | 16 + client-di2/src/hooks/use-mobile.tsx | 19 + client-di2/src/hooks/use-toast.ts | 191 ++ client-di2/src/index.css | 291 +++ client-di2/src/lib/format.ts | 49 + client-di2/src/lib/queryClient.ts | 57 + client-di2/src/lib/utils.ts | 6 + client-di2/src/main.tsx | 11 + client-di2/src/pages/audit.tsx | 299 +++ client-di2/src/pages/calculations.tsx | 1508 +++++++++++++ client-di2/src/pages/dashboard.tsx | 648 ++++++ client-di2/src/pages/debt.tsx | 681 ++++++ client-di2/src/pages/expense-categories.tsx | 1341 +++++++++++ client-di2/src/pages/expenses-overview.tsx | 1944 ++++++++++++++++ client-di2/src/pages/expenses.tsx | 517 +++++ client-di2/src/pages/heatmap.tsx | 384 ++++ client-di2/src/pages/income-categories.tsx | 1438 ++++++++++++ client-di2/src/pages/income-overview.tsx | 2007 +++++++++++++++++ client-di2/src/pages/income.tsx | 516 +++++ client-di2/src/pages/not-found.tsx | 21 + client-di2/src/pages/planning.tsx | 1297 +++++++++++ client-di2/src/pages/profitability.tsx | 396 ++++ client-di2/src/pages/salary.tsx | 1938 ++++++++++++++++ client-di2/src/pages/settings.tsx | 1626 +++++++++++++ client-di2/tailwind.config.ts | 108 + client-di2/tsconfig.json | 24 + .../components/finance/FinanceBottomNav.tsx | 9 + .../src/components/finance/FinanceSidebar.tsx | 14 + package.json | 8 +- vite.config.di2.ts | 22 + 87 files changed, 23728 insertions(+), 3 deletions(-) create mode 100644 .superpowers/sdd/di2-embed/task-2-report.md create mode 100644 client-di2/index.html create mode 100644 client-di2/shared/schema.ts create mode 100644 client-di2/src/App.tsx create mode 100644 client-di2/src/components/app-sidebar.tsx create mode 100644 client-di2/src/components/category-override-dialog.tsx create mode 100644 client-di2/src/components/column-filter.tsx create mode 100644 client-di2/src/components/copyable-cell.tsx create mode 100644 client-di2/src/components/database-error.tsx create mode 100644 client-di2/src/components/draggable-column-list.tsx create mode 100644 client-di2/src/components/ui/accordion.tsx create mode 100644 client-di2/src/components/ui/alert-dialog.tsx create mode 100644 client-di2/src/components/ui/alert.tsx create mode 100644 client-di2/src/components/ui/aspect-ratio.tsx create mode 100644 client-di2/src/components/ui/avatar.tsx create mode 100644 client-di2/src/components/ui/badge.tsx create mode 100644 client-di2/src/components/ui/breadcrumb.tsx create mode 100644 client-di2/src/components/ui/button.tsx create mode 100644 client-di2/src/components/ui/calendar.tsx create mode 100644 client-di2/src/components/ui/card.tsx create mode 100644 client-di2/src/components/ui/carousel.tsx create mode 100644 client-di2/src/components/ui/chart.tsx create mode 100644 client-di2/src/components/ui/checkbox.tsx create mode 100644 client-di2/src/components/ui/collapsible.tsx create mode 100644 client-di2/src/components/ui/command.tsx create mode 100644 client-di2/src/components/ui/context-menu.tsx create mode 100644 client-di2/src/components/ui/dialog.tsx create mode 100644 client-di2/src/components/ui/drawer.tsx create mode 100644 client-di2/src/components/ui/dropdown-menu.tsx create mode 100644 client-di2/src/components/ui/form.tsx create mode 100644 client-di2/src/components/ui/hover-card.tsx create mode 100644 client-di2/src/components/ui/input-otp.tsx create mode 100644 client-di2/src/components/ui/input.tsx create mode 100644 client-di2/src/components/ui/label.tsx create mode 100644 client-di2/src/components/ui/menubar.tsx create mode 100644 client-di2/src/components/ui/navigation-menu.tsx create mode 100644 client-di2/src/components/ui/pagination.tsx create mode 100644 client-di2/src/components/ui/popover.tsx create mode 100644 client-di2/src/components/ui/progress.tsx create mode 100644 client-di2/src/components/ui/radio-group.tsx create mode 100644 client-di2/src/components/ui/resizable.tsx create mode 100644 client-di2/src/components/ui/scroll-area.tsx create mode 100644 client-di2/src/components/ui/select.tsx create mode 100644 client-di2/src/components/ui/separator.tsx create mode 100644 client-di2/src/components/ui/sheet.tsx create mode 100644 client-di2/src/components/ui/sidebar.tsx create mode 100644 client-di2/src/components/ui/skeleton.tsx create mode 100644 client-di2/src/components/ui/slider.tsx create mode 100644 client-di2/src/components/ui/switch.tsx create mode 100644 client-di2/src/components/ui/table.tsx create mode 100644 client-di2/src/components/ui/tabs.tsx create mode 100644 client-di2/src/components/ui/textarea.tsx create mode 100644 client-di2/src/components/ui/toast.tsx create mode 100644 client-di2/src/components/ui/toaster.tsx create mode 100644 client-di2/src/components/ui/toggle-group.tsx create mode 100644 client-di2/src/components/ui/toggle.tsx create mode 100644 client-di2/src/components/ui/tooltip.tsx create mode 100644 client-di2/src/hooks/use-column-labels.ts create mode 100644 client-di2/src/hooks/use-mobile.tsx create mode 100644 client-di2/src/hooks/use-toast.ts create mode 100644 client-di2/src/index.css create mode 100644 client-di2/src/lib/format.ts create mode 100644 client-di2/src/lib/queryClient.ts create mode 100644 client-di2/src/lib/utils.ts create mode 100644 client-di2/src/main.tsx create mode 100644 client-di2/src/pages/audit.tsx create mode 100644 client-di2/src/pages/calculations.tsx create mode 100644 client-di2/src/pages/dashboard.tsx create mode 100644 client-di2/src/pages/debt.tsx create mode 100644 client-di2/src/pages/expense-categories.tsx create mode 100644 client-di2/src/pages/expenses-overview.tsx create mode 100644 client-di2/src/pages/expenses.tsx create mode 100644 client-di2/src/pages/heatmap.tsx create mode 100644 client-di2/src/pages/income-categories.tsx create mode 100644 client-di2/src/pages/income-overview.tsx create mode 100644 client-di2/src/pages/income.tsx create mode 100644 client-di2/src/pages/not-found.tsx create mode 100644 client-di2/src/pages/planning.tsx create mode 100644 client-di2/src/pages/profitability.tsx create mode 100644 client-di2/src/pages/salary.tsx create mode 100644 client-di2/src/pages/settings.tsx create mode 100644 client-di2/tailwind.config.ts create mode 100644 client-di2/tsconfig.json create mode 100644 vite.config.di2.ts diff --git a/.superpowers/sdd/di2-embed/task-2-report.md b/.superpowers/sdd/di2-embed/task-2-report.md new file mode 100644 index 0000000..6b9203b --- /dev/null +++ b/.superpowers/sdd/di2-embed/task-2-report.md @@ -0,0 +1,46 @@ +# Task 2 — Клиентский субапп Data-Insight2 (client-di2): отчёт + +## Статус: DONE + +## Что создано + +### `client-di2/` — копия фронтенда DI2 +- `src/` (78 файлов: App.tsx, pages/, components/ + ui/, hooks/, lib/, index.css), `public/favicon.png`, `index.html` — как есть, кроме правок ниже. +- `shared/schema.ts` ← копия `tmp/Data-Insight2/shared/schema.ts`. +- `tsconfig.json` — копия DI2-tsconfig: `include: ["src/**/*", "shared/**/*"]`, `target: ES2022` (сначала забыл — tsc ругался на ES5, добавлено), `strict`, paths `@/*`/`@shared/*` → `./src`, `./shared`, `types: ["node", "vite/client"]`, `tsBuildInfoFile: ../node_modules/typescript/tsbuildinfo-di2` (бриф предлагал `./node_modules/...` — функционально то же, но без мусорной папки client-di2/node_modules). +- `tailwind.config.ts` ← копия DI2-конфига (у DI2 свои токены/радиусы, отличные от iistwin!). Content-глобы: `./client-di2/index.html`, `./client-di2/src/**/*` — **относительно cwd сборки** (npm-скрипты всегда из корня). Вариант `{ relative: true, files: [...] }` относительно конфига проверен — глобы не матчились, утилиты вырезались (css 11 kB, 0 классов `.flex/.grid/...`); с cwd-глобами — 88 kB, утилиты на месте. +- `src/index.css` — добавлена директива `@config "../tailwind.config.ts";` в начало: без неё postcss-плагин tailwindcss от cwd (корень репо) взял бы корневой `tailwind.config.ts` iistwin и вырезал бы стили DI2. +- `src/main.tsx` — обёртка `` (wouter 3.10, проп `base` поддерживается — проверено в types/router.d.ts и фактической версии node_modules). +- `index.html` — favicon сделан относительным (`href="favicon.png"`), чтобы не 404 на проде; script-src `/src/main.tsx` — дев-режим, vite переписывает при сборке. + +### `vite.config.di2.ts` (корень) +Минимальный конфиг: `react()` без replit-плагинов, `base: "/di2/"`, `root: client-di2`, alias `@`/`@shared` → client-di2, `build.outDir: dist/public-di2`, `emptyOutDir: true`. `__BUILD_SHA__` в client-di2 не используется (grep пуст) — define не добавлял. server/fs-секций нет. + +### `package.json` +- `build`: `vite build && vite build -c vite.config.di2.ts && esbuild server/index.ts ...` (main client → di2 → server bundle); +- `build:di2`, `check`: `tsc && tsc -p client-di2/tsconfig.json`, `check:di2`. +- **Новых dependencies НЕ добавлено** — все пакеты DI2 (wouter, @tanstack/react-query, radix-набор, recharts, cmdk, vaul, embla, input-otp, date-fns, xlsx, tailwindcss-animate, @tailwindcss/typography и пр.) уже есть в package.json iistwin (проверено скриптом). + +### Прочее +- `Dockerfile` — менять не нужно: `RUN npm run build` подхватит новую сборку, `COPY --from=builder /app/dist ./dist` скопирует и public-di2. +- `.gitignore` — `dist` уже игнорируется, не тронут. +- **Навигация финансов**: desktop-список — `client/src/components/finance/FinanceSidebar.tsx` (пункт после navItems, plain `` с иконкой ExternalLink, стили как у Link, поддержка collapsed); мобильная — `client/src/components/finance/FinanceBottomNav.tsx` (пункт в DropdownMenu «Разделы» после категорий, тот же паттерн ``). Финансовые страницы оборачиваются в FinanceLayout → ссылка появляется на всех экранах финансов. + +### Типовые фиксы предсуществующих ошибок DI2 (DI2 под tsx никогда не прогонял tsc) +- `calculations.tsx`: литеральные массивы полей → `as any[]` (2 места), `addCumulativeVat(grouped as any, ...)` — дженерик `groupMonthlyData` выводил T как `{period: string}`. +- `income-categories.tsx` / `expense-categories.tsx`: в типы mutationFn добавлен `ruleLogic?: "and" | "or"` (UI его отправляет, типы отставали). +- `income.tsx` / `expenses.tsx` / `expenses-overview.tsx`: `new Set(...)` для filterableSet (строка не assignable в Set литерального union). +Рантайм-поведение не менялось. + +## Проверки +1. `npm run check` — **чисто** (оба tsc: основной + client-di2). +2. `npm run build` — **проходит**: main client (24.9s) → di2 (6.7s, `dist/public-di2/index.html` 2.03 kB, css 88.3 kB, js 1363 kB) → esbuild `dist\index.js` 2.7mb. +3. `ls dist/public-di2/` — `index.html`, `assets/index-*.css`, `assets/index-*.js`, `favicon.png`. +4. `grep -o 'src="[^"]*"' dist/public-di2/index.html` → `src="/di2/assets/index-D5btRW62.js"`, css `href="/di2/assets/index-CLXURD96.css"` — префикс `/di2/` на месте. +5. Стыковка с Task 1: серверный бандл `dist/index.js` лежит рядом с `dist/public-di2` → `path.resolve(import.meta.dirname, "public-di2")` из Task 1 сходится. Статика `/di2` отдастся через express.static + fallback sendFile index.html (глубокие ссылки wouter работают). + +## Открытые вопросы / опасения +- Рантайм-smoke в браузере не выполнялся (по брифу достаточно check/build + контроль путей). Первый реальный прогон — на проде после деплоя; если DI2-страница откроется неавторизованной — auth-gate из Task 1 редиректа не делает (вернёт 401 JSON на API, SPA покажет ошибки БД) — стоит глянуть глазами. +- DI2-SPA шрифты грузит с Google Fonts (как в оригинале) — на проде зависит от доступности fonts.googleapis.com. +- CSS предупреждение «A PostCSS plugin did not pass the `from` option» при di2-сборке — косметическое, на выхлоп не влияет. +- Размер di2-бандла 1.36 МБ без код-сплиттинга (как в оригинале DI2) — принято как есть. diff --git a/client-di2/index.html b/client-di2/index.html new file mode 100644 index 0000000..77b6691 --- /dev/null +++ b/client-di2/index.html @@ -0,0 +1,15 @@ + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/client-di2/shared/schema.ts b/client-di2/shared/schema.ts new file mode 100644 index 0000000..575052e --- /dev/null +++ b/client-di2/shared/schema.ts @@ -0,0 +1,366 @@ +import { z } from "zod"; + +export const dbConnectionSchema = z.object({ + host: z.string().min(1), + port: z.coerce.number().int().positive(), + database: z.string().min(1), + user: z.string().min(1), + password: z.string().min(1), +}); + +export type DbConnectionConfig = z.infer; + +export interface TableConfig { + incomeTable: string; + expenseTable: string; + debtTable: string; + incomeSumColumn: string; + expenseSumColumn: string; + incomeColumns: string[]; + expenseColumns: string[]; + columnLabels: Record; +} + +export interface IncomeRow { + id: string; + data: string | null; + summa: number | null; + stavkands: string | null; + dogovorkontragenta: string | null; + zakazchik: string | null; + dogovorzakazchika: string | null; + otvetstvenniipokontraktu: string | null; + statyadop: string | null; + kommentarii: string | null; + statya: string | null; + dochka: string | null; + vnuchka: string | null; + naznachenieplatezha: string | null; + schetnaoplatu: string | null; + overrideCategoryId?: string; + overrideCategoryName?: string; +} + +export interface ExpenseRow { + id: string; + data: string | null; + summadokumenta: number | null; + stavkands: string | null; + dogovorkontragenta: string | null; + zakazchik: string | null; + dogovorzakazchika: string | null; + otvetstvenniipokontraktu: string | null; + statyadop: string | null; + kommentarii: string | null; + statya: string | null; + dochka: string | null; + vnuchka: string | null; + naznachenieplatezha: string | null; + ssilkanomervhodyaschegodokumenta: string | null; + kontragent: string | null; + dataobnovleniyazapisi: string | null; + overrideCategoryId?: string; + overrideCategoryName?: string; +} + +export interface DebtRow { + period: string | null; + schet: string | null; + kontragent: string | null; + dogovor: string | null; + dokumentraschetov: string | null; + organizaciya: string | null; + summadolga: number | null; + summadolgaval: number | null; + valyuta: string | null; + otsrochkapodogovoru: number | null; + kontragentinn: string | null; + dataobrazovaniyadolga: string | null; + summadokumenta: number | null; + planovayadatapogasheniya: string | null; + dneiprosrocheno: number | null; + dneidolga: number | null; + otvetstvennii: string | null; + klassprosrochki: string | null; + klassdolga: string | null; + summaprosrocheno: number | null; + summaoplacheno: number | null; +} + +export interface DashboardStats { + incomeTotal: number; + incomeCount: number; + expenseTotal: number; + expenseCount: number; + debtTotal: number; + debtOverdue: number; + debtCounterparts: number; + monthlyIncome: { month: string; total: number }[]; + monthlyExpense: { month: string; total: number }[]; + debtByClass: { class: string; total: number }[]; + billedVsPaid: { month: string; billed: number; paid: number; debt: number }[]; +} + +export interface FilterOptions { + statya: string[]; + zakazchik: string[]; + dochka: string[]; + vnuchka: string[]; + otvetstvennii?: string[]; + organizaciya?: string[]; + klassprosrochki?: string[]; +} + +export interface PaginatedResult { + rows: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + totalSum?: number; +} + +export type RuleOperator = "equals" | "not_equals" | "contains" | "greater_than" | "less_than" | "equal_number"; + +export interface IncomeCategoryRule { + column: string; + operator: RuleOperator; + value: string; +} + +export const MAX_CATEGORY_DEPTH = 5; + +export interface IncomeCategory { + id: string; + name: string; + rules: IncomeCategoryRule[]; + ruleLogic?: "and" | "or"; + excluded?: boolean; + children?: IncomeCategory[]; + autoDetected?: boolean; + treeId?: string; +} + +export const NUMERIC_OPERATORS: { value: RuleOperator; label: string }[] = [ + { value: "equal_number", label: "равно (=)" }, + { value: "greater_than", label: "больше (>)" }, + { value: "less_than", label: "меньше (<)" }, +]; + +export const TEXT_OPERATORS: { value: RuleOperator; label: string }[] = [ + { value: "equals", label: "равно (=)" }, + { value: "not_equals", label: "не равно (≠)" }, + { value: "contains", label: "содержит (∋)" }, +]; + +export interface ProfitabilityRow { + zakazchik: string; + totalIncome: number; + totalExpense: number; + profit: number; + margin: number; + contractCount: number; +} + +export interface ContractProfitRow { + dogovorzakazchika: string; + totalIncome: number; + totalExpense: number; + profit: number; +} + +export interface MonthlyProfitRow { + month: string; + income: number; + expense: number; + profit: number; +} + +export interface ProfitabilitySummary { + totalIncome: number; + totalExpense: number; + totalProfit: number; + counterpartCount: number; +} + +export interface ProfitabilityResponse { + rows: ProfitabilityRow[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + summary: ProfitabilitySummary; +} + +export interface DebtByEntity { + name: string; + total: number; + cls0: number; + cls1_30: number; + cls31_60: number; + cls61_90: number; + cls91_365: number; + cls365plus: number; +} + +export interface DebtOverdueClass { + cls: string; + total: number; +} + +export interface DebtAnalytics { + summary: { + updatedAt: string | null; + totalDebt: number; + totalOverdue: number; + }; + byKontragent: DebtByEntity[]; + byManager: DebtByEntity[]; + byOverdueClass: DebtOverdueClass[]; + table: { + rows: DebtTableGroup[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }; +} + +export interface DebtTableRow { + dogovor: string; + summadokumenta: number; + dokumentraschetov: string; + summaoplacheno: number; + summadolga: number; + summaprosrocheno: number; + otsrochkapodogovoru: number; + dneiprosrocheno: number; +} + +export interface DebtTableGroup { + kontragent: string; + totalDebt: number; + totalOverdue: number; + rows: DebtTableRow[]; +} + +export interface DebtDynamicsRow { + period: string; + totalDebt: number; + totalOverdue: number; +} + +export interface DebtDynamicsKontragent { + kontragent: string; + summadokumenta: number; + summaoplacheno: number; + summadolga: number; + summaprosrocheno: number; + dneiprosrocheno: number; +} + +export interface DebtFiltersResponse { + periods: string[]; + klassprosrochki: string[]; + organizaciya: string[]; + otvetstvennii: string[]; + kontragenty: string[]; +} + +export const INCOME_FILTERABLE_COLUMNS = [ + { key: "data", label: "Дата" }, + { key: "summa", label: "Сумма" }, + { key: "statya", label: "Статья" }, + { key: "zakazchik", label: "Заказчик" }, + { key: "dochka", label: "Дочка" }, + { key: "vnuchka", label: "Внучка" }, + { key: "otvetstvenniipokontraktu", label: "Ответственный" }, + { key: "stavkands", label: "Ставка НДС" }, + { key: "kommentarii", label: "Комментарий" }, + { key: "dogovorkontragenta", label: "Договор контрагента" }, + { key: "schetnaoplatu", label: "Счет на оплату" }, + { key: "naznachenieplatezha", label: "Назначение платежа" }, + { key: "dogovorzakazchika", label: "Договор заказчика" }, + { key: "statyadop", label: "Статья (доп.)" }, + { key: "nomervhodyaschegodokumenta", label: "Номер вх. документа" }, +] as const; + +export const ALL_INCOME_COLUMNS: { key: string; label: string; type: "date" | "number" | "text" }[] = [ + { key: "data", label: "Дата", type: "date" }, + { key: "summa", label: "Сумма", type: "number" }, + { key: "statya", label: "Статья", type: "text" }, + { key: "zakazchik", label: "Заказчик", type: "text" }, + { key: "dochka", label: "Дочка", type: "text" }, + { key: "vnuchka", label: "Внучка", type: "text" }, + { key: "otvetstvenniipokontraktu", label: "Ответственный", type: "text" }, + { key: "stavkands", label: "Ставка НДС", type: "text" }, + { key: "kommentarii", label: "Комментарий", type: "text" }, + { key: "dogovorkontragenta", label: "Договор контрагента", type: "text" }, + { key: "schetnaoplatu", label: "Счет на оплату", type: "text" }, + { key: "naznachenieplatezha", label: "Назначение платежа", type: "text" }, + { key: "dogovorzakazchika", label: "Договор заказчика", type: "text" }, + { key: "statyadop", label: "Статья (доп.)", type: "text" }, + { key: "nomervhodyaschegodokumenta", label: "Номер вх. документа", type: "text" }, + { key: "id", label: "ID", type: "text" }, +]; + +export const DEFAULT_VISIBLE_COLUMNS = ["data", "summa", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii"]; + +export const ALL_EXPENSE_COLUMNS: { key: string; label: string; type: "date" | "number" | "text" }[] = [ + { key: "data", label: "Дата", type: "date" }, + { key: "summadokumenta", label: "Сумма документа", type: "number" }, + { key: "statya", label: "Статья", type: "text" }, + { key: "zakazchik", label: "Заказчик", type: "text" }, + { key: "dochka", label: "Дочка", type: "text" }, + { key: "vnuchka", label: "Внучка", type: "text" }, + { key: "kommentarii", label: "Комментарий", type: "text" }, + { key: "stavkands", label: "Ставка НДС", type: "text" }, + { key: "naznachenieplatezha", label: "Назначение платежа", type: "text" }, + { key: "otvetstvenniipokontraktu", label: "Ответственный", type: "text" }, + { key: "dogovorkontragenta", label: "Договор контрагента", type: "text" }, + { key: "dogovorzakazchika", label: "Договор заказчика", type: "text" }, + { key: "statyadop", label: "Статья (доп.)", type: "text" }, + { key: "ssilkanomervhodyaschegodokumenta", label: "Номер вх. документа", type: "text" }, + { key: "kontragent", label: "Контрагент", type: "text" }, +]; + +export const EXPENSE_FILTERABLE_COLUMNS = [ + { key: "data", label: "Дата" }, + { key: "summadokumenta", label: "Сумма документа" }, + { key: "statya", label: "Статья" }, + { key: "zakazchik", label: "Заказчик" }, + { key: "dochka", label: "Дочка" }, + { key: "vnuchka", label: "Внучка" }, + { key: "kommentarii", label: "Комментарий" }, + { key: "stavkands", label: "Ставка НДС" }, + { key: "naznachenieplatezha", label: "Назначение платежа" }, + { key: "otvetstvenniipokontraktu", label: "Ответственный" }, + { key: "dogovorkontragenta", label: "Договор контрагента" }, + { key: "dogovorzakazchika", label: "Договор заказчика" }, + { key: "statyadop", label: "Статья (доп.)" }, + { key: "ssilkanomervhodyaschegodokumenta", label: "Номер вх. документа" }, + { key: "kontragent", label: "Контрагент" }, +] as const; + +export const DEFAULT_VISIBLE_EXPENSE_COLUMNS = ["data", "summadokumenta", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii"]; + +export const CATEGORY_RULE_COLUMNS = [ + ...INCOME_FILTERABLE_COLUMNS.map(c => ({ ...c, type: "text" as const })), + { key: "summa", label: "Сумма", type: "number" as const }, +]; + +export const EXPENSE_CATEGORY_RULE_COLUMNS = [ + ...EXPENSE_FILTERABLE_COLUMNS.filter(c => c.key !== "summadokumenta").map(c => ({ ...c, type: "text" as const })), + { key: "summadokumenta", label: "Сумма документа", type: "number" as const }, +]; + +export interface ExpenseCategory { + id: string; + name: string; + rules: IncomeCategoryRule[]; + ruleLogic?: "and" | "or"; + excluded?: boolean; + children?: ExpenseCategory[]; + autoDetected?: boolean; + treeId?: string; +} diff --git a/client-di2/src/App.tsx b/client-di2/src/App.tsx new file mode 100644 index 0000000..055e96b --- /dev/null +++ b/client-di2/src/App.tsx @@ -0,0 +1,183 @@ +import { useState, Component, type ReactNode } from "react"; +import { Switch, Route } from "wouter"; +import { queryClient } from "./lib/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { AppSidebar } from "@/components/app-sidebar"; +import { RefreshCw, ArrowLeft } from "lucide-react"; +import NotFound from "@/pages/not-found"; +import Dashboard from "@/pages/dashboard"; +import Income from "@/pages/income"; +import IncomeOverview from "@/pages/income-overview"; +import IncomeCategories from "@/pages/income-categories"; +import Profitability from "@/pages/profitability"; +import Expenses from "@/pages/expenses"; +import ExpensesOverview from "@/pages/expenses-overview"; +import ExpenseCategories from "@/pages/expense-categories"; +import Debt from "@/pages/debt"; +import Calculations from "@/pages/calculations"; +import Planning from "@/pages/planning"; +import Salary from "@/pages/salary"; +import Settings from "@/pages/settings"; +import Audit from "@/pages/audit"; +import Heatmap from "@/pages/heatmap"; + +class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean; error: Error | null }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + render() { + if (this.state.hasError) { + return ( +
+
⚠️
+

Что-то пошло не так

+

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

+ + {this.state.error && ( +
+              {this.state.error.message}
+            
+ )} +
+ ); + } + return this.props.children; + } +} + +function Router() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +function App() { + const [refreshing, setRefreshing] = useState(false); + const [refreshStatus, setRefreshStatus] = useState(null); + + async function handleRefreshData() { + setRefreshing(true); + setRefreshStatus("Синхронизация..."); + + const pollInterval = setInterval(async () => { + try { + const r = await fetch("/api/admin/rebuild-status"); + const s = await r.json(); + if (s.inProgress) setRefreshStatus("Пересборка деревьев..."); + else if (s.pendingRebuild) setRefreshStatus("Ожидание пересборки..."); + } catch {} + }, 600); + + try { + const res = await fetch("/api/refresh-all", { method: "POST" }); + setRefreshStatus(res.ok ? "Готово" : "Ошибка"); + } catch { + try { await fetch("/api/cache/invalidate", { method: "POST" }); } catch {} + setRefreshStatus("Готово"); + } finally { + clearInterval(pollInterval); + await queryClient.invalidateQueries(); + await queryClient.refetchQueries(); + setTimeout(() => { + setRefreshStatus(null); + setRefreshing(false); + }, 1500); + } + } + + const style = { + "--sidebar-width": "16rem", + "--sidebar-width-icon": "3.5rem", + }; + + return ( + + + + +
+ +
+
+
+ + +
+ + + + + +

{refreshStatus ?? "Синхронизировать категории и пересобрать данные"}

+
+
+
+
+ +
+
+
+
+ +
+
+
+ ); +} + +export default App; diff --git a/client-di2/src/components/app-sidebar.tsx b/client-di2/src/components/app-sidebar.tsx new file mode 100644 index 0000000..4d9c643 --- /dev/null +++ b/client-di2/src/components/app-sidebar.tsx @@ -0,0 +1,164 @@ +import { useCallback } from "react"; +import { useLocation } from "wouter"; +import { queryClient } from "@/lib/queryClient"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarHeader, +} from "@/components/ui/sidebar"; +import { LayoutDashboard, TrendingUp, Wallet, AlertCircle, Settings, Database, Handshake, Calculator, CalendarRange, Banknote, ShieldCheck, Grid3X3 } from "lucide-react"; + +const settingsItems = [ + { title: "Настройки", url: "/settings", icon: Settings }, +]; + +export function AppSidebar() { + const [location] = useLocation(); + + const prefetch = useCallback((keys: string[]) => { + keys.forEach(key => { + queryClient.prefetchQuery({ queryKey: [key] }); + }); + }, []); + + const isIncomeSection = location.startsWith("/income"); + const isExpenseSection = location.startsWith("/expenses"); + + return ( + + +
+
+ +
+
+
ОмегаПром
+
Финансы
+
+
+
+ + + Аналитика + + + + +
prefetch(["/api/dashboard"])}> + + Дашборд + + + + + + + prefetch(["/api/income/tree-summary"])}> + + Доходы + + + + + + + + + Прибыльность + + + + + + + prefetch(["/api/expense-categories"])}> + + Расходы + + + + + + + prefetch(["/api/debt/filters", "/api/debt/analytics"])}> + + Дебиторская задолженность + + + + + + + + + Расчеты + + + + + + + + + Планирование + + + + + + + + + Зарплата + + + + + + + + + Аудит данных + + + + + + + + + Тепловая карта + + + + + + + + + Настройки + + + {settingsItems.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + ); +} diff --git a/client-di2/src/components/category-override-dialog.tsx b/client-di2/src/components/category-override-dialog.tsx new file mode 100644 index 0000000..cb690e1 --- /dev/null +++ b/client-di2/src/components/category-override-dialog.tsx @@ -0,0 +1,482 @@ +import { useState, useMemo, useEffect, useCallback, useRef } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ChevronRight, ChevronDown, Folder, FolderOpen, Search, X, RotateCcw } from "lucide-react"; +import { queryClient } from "@/lib/queryClient"; +import type { IncomeCategory } from "@shared/schema"; + +interface CategoryOverrideDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + recordId: string; + recordType: "income" | "expense"; + currentOverrideCategoryId?: string; + contextTreeId?: string; +} + +interface AutoDetectTree { + id: string; + name: string; + isPrimary?: boolean; +} + +interface RootGroup { + id: string; + name: string; + categories: IncomeCategory[]; +} + +function collectAncestorIds(cats: IncomeCategory[], targetId: string, path: string[] = []): string[] | null { + for (const cat of cats) { + if (cat.excluded) continue; + if (cat.id === targetId) return path; + if (cat.children && cat.children.length > 0) { + const found = collectAncestorIds(cat.children, targetId, [...path, cat.id]); + if (found) return found; + } + } + return null; +} + +function buildRootGroups( + categories: IncomeCategory[], + trees: AutoDetectTree[] +): RootGroup[] { + const groups: RootGroup[] = []; + + const manual = categories.filter(c => !c.treeId); + if (manual.length > 0) { + groups.push({ id: "__manual__", name: "Ручные категории", categories: manual }); + } + + for (const tree of trees) { + if (tree.isPrimary === false) continue; + const treeCats = categories.filter(c => c.treeId === tree.id); + if (treeCats.length > 0) { + groups.push({ id: tree.id, name: tree.name, categories: treeCats }); + } + } + + return groups; +} + +function computeInitialExpandedForGroups(groups: RootGroup[], overrideId?: string, contextTreeId?: string): Set { + if (!groups) return new Set(); + const result = new Set(); + if (overrideId) { + for (const group of groups) { + const path = collectAncestorIds(group.categories, overrideId); + if (path !== null) { + result.add(group.id); + for (const id of path) result.add(id); + break; + } + } + } + if (contextTreeId && !result.has(contextTreeId)) { + const exists = groups.some(g => g.id === contextTreeId); + if (exists) result.add(contextTreeId); + } + return result; +} + +function collectMatchIdsForGroups( + groups: RootGroup[], + query: string +): { matching: Set; ancestors: Set; catRootName: Map } { + const matching = new Set(); + const ancestors = new Set(); + const catRootName = new Map(); + + for (const group of groups) { + function walk(cats: IncomeCategory[], parentIds: string[]): boolean { + let anyMatch = false; + for (const cat of cats) { + if (cat.excluded) continue; + const nameMatches = cat.name.toLowerCase().includes(query); + let childMatch = false; + if (cat.children && cat.children.length > 0) { + childMatch = walk(cat.children, [...parentIds, cat.id]); + } + if (nameMatches || childMatch) { + matching.add(cat.id); + for (const pid of parentIds) ancestors.add(pid); + if (nameMatches && !catRootName.has(cat.id)) catRootName.set(cat.id, group.name); + anyMatch = true; + } + } + return anyMatch; + } + walk(group.categories, []); + } + + return { matching, ancestors, catRootName }; +} + +function hasNonExcludedChildren(cat: IncomeCategory): boolean { + return !!(cat.children && cat.children.some(c => !c.excluded)); +} + +interface TreeNodeProps { + cat: IncomeCategory; + depth: number; + expanded: Set; + onToggle: (id: string) => void; + onSelect: (id: string) => void; + currentOverrideCategoryId?: string; + isPending: boolean; + searchActive: boolean; + matchingIds: Set; + ancestorIds: Set; + catRootName?: Map; +} + +function TreeNode({ cat, depth, expanded, onToggle, onSelect, currentOverrideCategoryId, isPending, searchActive, matchingIds, ancestorIds, catRootName }: TreeNodeProps) { + if (cat.excluded) return null; + if (searchActive && !matchingIds.has(cat.id) && !ancestorIds.has(cat.id)) return null; + + const hasChildren = hasNonExcludedChildren(cat); + const isExpanded = expanded.has(cat.id); + const isSelected = cat.id === currentOverrideCategoryId; + const isSearchAncestorOnly = searchActive && ancestorIds.has(cat.id) && !matchingIds.has(cat.id); + const rootName = searchActive && catRootName ? catRootName.get(cat.id) : undefined; + + const label = ( + <> + {cat.name} + {rootName && ( + {rootName} + )} + {cat.autoDetected && !rootName && ( + авто + )} + {isSelected && ( + текущая + )} + + ); + + return ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + +
+ {hasChildren && isExpanded && ( +
+ {cat.children!.map(child => ( + + ))} +
+ )} +
+ ); +} + +interface RootGroupRowProps { + group: RootGroup; + expanded: Set; + onToggle: (id: string) => void; + onSelect: (id: string) => void; + currentOverrideCategoryId?: string; + isPending: boolean; + searchActive: boolean; + matchingIds: Set; + ancestorIds: Set; + catRootName: Map; + contextTreeId?: string; +} + +function RootGroupRow({ group, expanded, onToggle, onSelect, currentOverrideCategoryId, isPending, searchActive, matchingIds, ancestorIds, catRootName, contextTreeId }: RootGroupRowProps) { + const isExpanded = expanded.has(group.id); + const isContext = group.id === contextTreeId; + + const hasVisibleChildren = searchActive + ? group.categories.some(c => matchingIds.has(c.id) || ancestorIds.has(c.id)) + : group.categories.some(c => !c.excluded); + + if (searchActive && !hasVisibleChildren) return null; + + return ( +
+
onToggle(group.id)} + data-testid={`root-group-${group.id}`} + > + + {isExpanded + ? + : } + + {isExpanded + ? + : } + {group.name} + {isContext && ( + текущий + )} +
+ {(isExpanded || searchActive) && ( +
+ {group.categories.map(cat => ( + + ))} +
+ )} +
+ ); +} + +export function CategoryOverrideDialog({ open, onOpenChange, recordId, recordType, currentOverrideCategoryId, contextTreeId }: CategoryOverrideDialogProps) { + const [search, setSearch] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + + const catEndpoint = recordType === "income" ? "/api/income-categories" : "/api/expense-categories"; + const configEndpoint = recordType === "income" + ? "/api/income-categories/auto-detect/config" + : "/api/expense-categories/auto-detect/config"; + + const { data: categories, isLoading: catsLoading } = useQuery({ + queryKey: [catEndpoint], + enabled: open, + }); + + const { data: autoDetectConfig, isLoading: configLoading } = useQuery<{ trees: AutoDetectTree[] }>({ + queryKey: [configEndpoint], + enabled: open, + }); + + const isLoadingAny = catsLoading || configLoading; + + const rootGroups = useMemo(() => { + if (!categories) return []; + const trees = autoDetectConfig?.trees ?? []; + return buildRootGroups(categories, trees); + }, [categories, autoDetectConfig]); + + const computeInitialExpanded = useCallback((groups: RootGroup[], overrideId?: string, ctxTreeId?: string): Set => { + return computeInitialExpandedForGroups(groups, overrideId, ctxTreeId); + }, []); + + useEffect(() => { + if (open && rootGroups.length > 0) { + setExpanded(computeInitialExpanded(rootGroups, currentOverrideCategoryId, contextTreeId)); + } + if (!open) { + setSearch(""); + setExpanded(new Set()); + } + }, [open, rootGroups, currentOverrideCategoryId, contextTreeId, computeInitialExpanded]); + + const prevSearchActiveRef = useRef(false); + useEffect(() => { + const wasActive = prevSearchActiveRef.current; + const isActive = search.trim().length > 0; + prevSearchActiveRef.current = isActive; + if (wasActive && !isActive) { + setExpanded(computeInitialExpanded(rootGroups, currentOverrideCategoryId, contextTreeId)); + } + }, [search, rootGroups, currentOverrideCategoryId, contextTreeId, computeInitialExpanded]); + + const { matching: matchingIds, ancestors: searchAncestorIds, catRootName } = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q || rootGroups.length === 0) { + return { matching: new Set(), ancestors: new Set(), catRootName: new Map() }; + } + return collectMatchIdsForGroups(rootGroups, q); + }, [rootGroups, search]); + + const searchActive = search.trim().length > 0; + + const effectiveExpanded = useMemo(() => { + if (!searchActive) return expanded; + const allGroupIds = new Set(rootGroups.map(g => g.id)); + return new Set([...allGroupIds, ...searchAncestorIds, ...matchingIds]); + }, [searchActive, expanded, searchAncestorIds, matchingIds, rootGroups]); + + const hasAnyVisible = useMemo(() => { + if (rootGroups.length === 0) return false; + if (!searchActive) return true; + return matchingIds.size > 0 || searchAncestorIds.size > 0; + }, [rootGroups, searchActive, matchingIds, searchAncestorIds]); + + const onToggle = useCallback((id: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const setOverrideMutation = useMutation({ + mutationFn: async (categoryId: string) => { + const res = await fetch("/api/overrides", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: recordType, recordId, categoryId }), + }); + if (!res.ok) throw new Error("Failed"); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/overrides"] }); + queryClient.invalidateQueries({ predicate: (q) => { + const key = q.queryKey[0]; + if (typeof key !== "string") return false; + return key.includes("tree-summary") || key.includes("/api/income") || key.includes("/api/expenses"); + }}); + onOpenChange(false); + }, + }); + + const removeOverrideMutation = useMutation({ + mutationFn: async () => { + const res = await fetch(`/api/overrides/${recordType}/${encodeURIComponent(recordId)}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed"); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/overrides"] }); + queryClient.invalidateQueries({ predicate: (q) => { + const key = q.queryKey[0]; + if (typeof key !== "string") return false; + return key.includes("tree-summary") || key.includes("/api/income") || key.includes("/api/expenses"); + }}); + onOpenChange(false); + }, + }); + + const isPending = setOverrideMutation.isPending || removeOverrideMutation.isPending; + + const onSelect = useCallback((catId: string) => { + setOverrideMutation.mutate(catId); + }, [setOverrideMutation]); + + return ( + + + + Назначить категорию + + +
+ + setSearch(e.target.value)} + className="pl-9 h-9 text-sm" + data-testid="input-category-search" + /> + {search && ( + + )} +
+ + {currentOverrideCategoryId && ( +
+ + Назначена вручную + + +
+ )} + +
+ {isLoadingAny || !hasAnyVisible ? ( +
+ {isLoadingAny ? "Загрузка..." : "Категории не найдены"} +
+ ) : ( +
+ {rootGroups.map(group => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/client-di2/src/components/column-filter.tsx b/client-di2/src/components/column-filter.tsx new file mode 100644 index 0000000..f32f36c --- /dev/null +++ b/client-di2/src/components/column-filter.tsx @@ -0,0 +1,148 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { X, Search } from "lucide-react"; + +interface ColumnFilterProps { + column: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + apiEndpoint?: string; + contextFilters?: Record; +} + +export function ColumnFilter({ column, value, onChange, placeholder = "Поиск...", apiEndpoint = "/api/income/column-values", contextFilters }: ColumnFilterProps) { + const [inputValue, setInputValue] = useState(value); + const [suggestions, setSuggestions] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const wrapperRef = useRef(null); + const debounceRef = useRef>(); + + const contextKey = useMemo(() => { + if (!contextFilters) return ""; + const active = Object.entries(contextFilters).filter(([, v]) => v).sort(([a], [b]) => a.localeCompare(b)); + return active.length > 0 ? JSON.stringify(Object.fromEntries(active)) : ""; + }, [contextFilters]); + + useEffect(() => { + setInputValue(value); + }, [value]); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const fetchSuggestions = useCallback((search: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ column }); + if (search) params.set("search", search); + if (contextKey) { + params.set("context", contextKey); + } + const res = await fetch(`${apiEndpoint}?${params}`); + const data = await res.json(); + setSuggestions(Array.isArray(data) ? data : []); + } catch { + setSuggestions([]); + } finally { + setLoading(false); + } + }, 250); + }, [column, apiEndpoint, contextKey]); + + function handleInputChange(val: string) { + setInputValue(val); + setOpen(true); + fetchSuggestions(val); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + onChange(inputValue); + setOpen(false); + } + } + + function handleBlur() { + setTimeout(() => { + if (inputValue !== value) { + onChange(inputValue); + } + }, 200); + } + + function handleFocus() { + setOpen(true); + fetchSuggestions(inputValue); + } + + function handleSelect(val: string) { + setInputValue(val); + onChange(val); + setOpen(false); + } + + function handleClear() { + setInputValue(""); + onChange(""); + setOpen(false); + } + + return ( +
+
+
+ + handleInputChange(e.target.value)} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + placeholder={placeholder} + className="w-full h-7 pl-5 pr-1.5 text-xs bg-background border border-border rounded-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data-testid={`column-filter-${column}`} + /> +
+ {value && ( + + )} +
+ {open && (suggestions.length > 0 || loading) && ( +
+ {loading && suggestions.length === 0 ? ( +
Загрузка...
+ ) : ( + suggestions.map((s, i) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/client-di2/src/components/copyable-cell.tsx b/client-di2/src/components/copyable-cell.tsx new file mode 100644 index 0000000..1e07c39 --- /dev/null +++ b/client-di2/src/components/copyable-cell.tsx @@ -0,0 +1,58 @@ +import { useState, useRef, useCallback, useEffect } from "react"; + +interface CopyableCellProps { + text: string; + className?: string; +} + +export function CopyableCell({ text, className = "" }: CopyableCellProps) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + + const handleClick = useCallback((e: React.MouseEvent) => { + const el = triggerRef.current; + if (!el) return; + if (el.scrollWidth <= el.clientWidth) return; + e.stopPropagation(); + const rect = el.getBoundingClientRect(); + setPos({ top: rect.bottom + 4, left: rect.left }); + setOpen(true); + }, []); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node) && + triggerRef.current && !triggerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + return ( + <> + + {text} + + {open && pos && ( +
+ {text} +
+ )} + + ); +} diff --git a/client-di2/src/components/database-error.tsx b/client-di2/src/components/database-error.tsx new file mode 100644 index 0000000..883c11a --- /dev/null +++ b/client-di2/src/components/database-error.tsx @@ -0,0 +1,18 @@ +import { AlertTriangle } from "lucide-react"; + +export function DatabaseError({ error }: { error?: Error | null }) { + const isConnectionError = error?.message?.includes("503"); + const message = isConnectionError + ? "База данных не подключена. Проверьте настройки подключения." + : "Не удалось загрузить данные. Попробуйте обновить страницу."; + + return ( +
+ + {message} +
+ ); +} diff --git a/client-di2/src/components/draggable-column-list.tsx b/client-di2/src/components/draggable-column-list.tsx new file mode 100644 index 0000000..4437acb --- /dev/null +++ b/client-di2/src/components/draggable-column-list.tsx @@ -0,0 +1,112 @@ +import { useState, useRef, useCallback } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { GripVertical } from "lucide-react"; + +interface ColumnDef { + key: string; + label: string; +} + +interface DraggableColumnListProps { + allColumns: ColumnDef[]; + columnOrder: string[]; + visibleCols: string[]; + onOrderChange: (newOrder: string[]) => void; + onToggle: (key: string) => void; + onShowAll: () => void; + onReset: () => void; +} + +export function DraggableColumnList({ + allColumns, + columnOrder, + visibleCols, + onOrderChange, + onToggle, + onShowAll, + onReset, +}: DraggableColumnListProps) { + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + const dragRef = useRef(null); + + const colMap = new Map(allColumns.map(c => [c.key, c])); + const ordered = columnOrder + .filter(k => colMap.has(k)) + .map(k => colMap.get(k)!); + const missing = allColumns.filter(c => !columnOrder.includes(c.key)); + const finalList = [...ordered, ...missing]; + + const handleDragStart = useCallback((e: React.DragEvent, idx: number) => { + dragRef.current = idx; + setDragIdx(idx); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, idx: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setOverIdx(idx); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, dropIdx: number) => { + e.preventDefault(); + const fromIdx = dragRef.current; + if (fromIdx === null || fromIdx === dropIdx) { + setDragIdx(null); + setOverIdx(null); + return; + } + const newOrder = finalList.map(c => c.key); + const [moved] = newOrder.splice(fromIdx, 1); + newOrder.splice(dropIdx, 0, moved); + onOrderChange(newOrder); + setDragIdx(null); + setOverIdx(null); + }, [finalList, onOrderChange]); + + const handleDragEnd = useCallback(() => { + setDragIdx(null); + setOverIdx(null); + }, []); + + return ( + <> +
Отображаемые столбцы
+
+ {finalList.map((col, idx) => ( +
handleDragStart(e, idx)} + onDragOver={e => handleDragOver(e, idx)} + onDrop={e => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + className={`flex items-center gap-1.5 cursor-grab active:cursor-grabbing text-sm rounded px-1 py-1 select-none transition-colors ${ + dragIdx === idx ? "opacity-40 bg-muted" : "" + } ${overIdx === idx && dragIdx !== idx ? "border-t-2 border-primary" : ""} hover:bg-muted/50`} + data-testid={`draggable-col-${col.key}`} + > + + onToggle(col.key)} + data-testid={`checkbox-table-col-${col.key}`} + /> + {col.label} +
+ ))} +
+
+ + +
+ + ); +} diff --git a/client-di2/src/components/ui/accordion.tsx b/client-di2/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e6a723d --- /dev/null +++ b/client-di2/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/client-di2/src/components/ui/alert-dialog.tsx b/client-di2/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..8722561 --- /dev/null +++ b/client-di2/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/client-di2/src/components/ui/alert.tsx b/client-di2/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/client-di2/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/client-di2/src/components/ui/aspect-ratio.tsx b/client-di2/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/client-di2/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/client-di2/src/components/ui/avatar.tsx b/client-di2/src/components/ui/avatar.tsx new file mode 100644 index 0000000..fc7f964 --- /dev/null +++ b/client-di2/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/client-di2/src/components/ui/badge.tsx b/client-di2/src/components/ui/badge.tsx new file mode 100644 index 0000000..b59d7ad --- /dev/null +++ b/client-di2/src/components/ui/badge.tsx @@ -0,0 +1,38 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate " , + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: "border-transparent bg-secondary text-secondary-foreground", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + + outline: " border [border-color:var(--badge-outline)] shadow-xs", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants } diff --git a/client-di2/src/components/ui/breadcrumb.tsx b/client-di2/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/client-di2/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>