Merge pull request 'fix(web): синхронизация встроенного фронта с arm-web@test (025233f) (#115)' (#116) from feat/115-web-sync-025233f into main
This commit is contained in:
commit
e395edaa75
16
web/.prettierrc.json
Normal file
16
web/.prettierrc.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"printWidth": 140,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"bracketSameLine": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.css",
|
||||||
|
"options": { "singleQuote": false }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -13,9 +13,22 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
|
|||||||
`ARM_CORE_URL` (по умолчанию `http://localhost:8000`), `REACT_APP_API_URL` в dev не нужен.
|
`ARM_CORE_URL` (по умолчанию `http://localhost:8000`), `REACT_APP_API_URL` в dev не нужен.
|
||||||
4. Вход — через dev-форму (`POST /dev/token`), например `ka_go@armka.local`.
|
4. Вход — через dev-форму (`POST /dev/token`), например `ka_go@armka.local`.
|
||||||
|
|
||||||
|
## Форматирование и линтеры
|
||||||
|
|
||||||
|
**Перед PR — `npm run format`** (или `make format`): `eslint --fix` → `biome check --write` (импорты, безопасные
|
||||||
|
исправления) → `prettier --write`. Команда приводит `src/` к единому виду; повторный запуск ничего не меняет.
|
||||||
|
|
||||||
|
- Prettier (`.prettierrc.json`) — единственный форматтер: табы, ширина 140, одинарные кавычки (в CSS — двойные),
|
||||||
|
`>` тега JSX на той же строке. Форматтер biome выключен, чтобы два инструмента не спорили о пробелах.
|
||||||
|
- ESLint (`eslint.config.js`) — правила, повторяющие `biome.json`; biome остаётся линтером в `make lint`/`make check`.
|
||||||
|
- `npm run lint` — `eslint` + `prettier --check` без исправлений: ошибки валят команду, предупреждения (`any`,
|
||||||
|
`console`, зависимости хуков) — нет.
|
||||||
|
- В редакторе: расширения Prettier и ESLint, «Format on Save» с Prettier — результат совпадёт с `npm run format`.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
`make check` — install, biome, `tsc --noEmit`, vitest, `rsbuild build`. Зелёным считается всё вместе.
|
`make check` — install, biome + ESLint + Prettier (`make lint`), `tsc --noEmit`, vitest, `rsbuild build`. Зелёным
|
||||||
|
считается всё вместе.
|
||||||
|
|
||||||
## Демо-сценарий
|
## Демо-сценарий
|
||||||
|
|
||||||
|
|||||||
@ -10,9 +10,7 @@
|
|||||||
"ignore": ["node_modules"]
|
"ignore": ["node_modules"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": false
|
||||||
"lineWidth": 140,
|
|
||||||
"formatWithErrors": true
|
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@ -81,9 +79,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"css": {
|
"css": {
|
||||||
"formatter": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"parser": {
|
"parser": {
|
||||||
"cssModules": true
|
"cssModules": true
|
||||||
}
|
}
|
||||||
|
|||||||
46
web/eslint.config.js
Normal file
46
web/eslint.config.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// ESLint для разработчиков фронта. Правила повторяют biome.json (biome остаётся в make lint/check);
|
||||||
|
// форматирование — только Prettier, поэтому eslint-config-prettier подключается последним.
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import react from 'eslint-plugin-react';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['node_modules', 'dist'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
languageOptions: { globals: globals.browser },
|
||||||
|
plugins: { react, 'react-hooks': reactHooks },
|
||||||
|
settings: { react: { version: 'detect' } },
|
||||||
|
rules: {
|
||||||
|
// correctness
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
// nursery
|
||||||
|
'no-dupe-else-if': 'error',
|
||||||
|
'no-nested-ternary': 'error',
|
||||||
|
'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }],
|
||||||
|
// style
|
||||||
|
'default-case': 'error',
|
||||||
|
'@typescript-eslint/prefer-for-of': 'error',
|
||||||
|
'react/jsx-fragments': ['error', 'syntax'],
|
||||||
|
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||||
|
'operator-assignment': 'error',
|
||||||
|
// suspicious
|
||||||
|
'no-console': 'warn',
|
||||||
|
'no-empty': 'error',
|
||||||
|
'@typescript-eslint/no-empty-function': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'require-await': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettier,
|
||||||
|
);
|
||||||
@ -6,6 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rsbuild build",
|
"build": "rsbuild build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"format": "eslint --fix src && biome check --write src && prettier --write src",
|
||||||
|
"lint": "eslint src && prettier --check src",
|
||||||
"start": "rsbuild dev --open",
|
"start": "rsbuild dev --open",
|
||||||
"preview": "rsbuild preview"
|
"preview": "rsbuild preview"
|
||||||
},
|
},
|
||||||
@ -23,13 +25,21 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
|
"@eslint/js": "9.17.0",
|
||||||
"@rsbuild/core": "1.6.7",
|
"@rsbuild/core": "1.6.7",
|
||||||
"@rsbuild/plugin-react": "^1.1.0",
|
"@rsbuild/plugin-react": "^1.1.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^4.12.10",
|
||||||
|
"eslint": "9.17.0",
|
||||||
|
"eslint-config-prettier": "9.1.0",
|
||||||
|
"eslint-plugin-react": "7.37.3",
|
||||||
|
"eslint-plugin-react-hooks": "5.1.0",
|
||||||
|
"globals": "15.14.0",
|
||||||
|
"prettier": "3.3.3",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "8.18.2",
|
||||||
"vitest": "3.1.4"
|
"vitest": "3.1.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
// wasm-декодеры pdf.js (JBIG2, JPEG2000, ICC): без них скан не рисуется (AURORA-1635). Путь = PDFJS_WASM_DIR
|
||||||
|
copy: [{ from: 'node_modules/pdfjs-dist/wasm', to: 'static/pdfjs/wasm' }],
|
||||||
filename: {
|
filename: {
|
||||||
js: '[name]_script.[contenthash:8].js',
|
js: '[name]_script.[contenthash:8].js',
|
||||||
css: '[name]_style.[contenthash:8].css',
|
css: '[name]_style.[contenthash:8].css',
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export interface TableConfig<T> {
|
|||||||
columns: ColumnDef<T>[];
|
columns: ColumnDef<T>[];
|
||||||
searchableKeys?: Extract<keyof T, string>[];
|
searchableKeys?: Extract<keyof T, string>[];
|
||||||
}
|
}
|
||||||
export type ColumnFilters<T> = Partial<Record<Extract<keyof T, string>, Array<string | number>>>;
|
export type ColumnFilters<T> = Partial<Record<Extract<keyof T, string>, (string | number)[]>>;
|
||||||
export function isGroup<T>(c: ColumnDef<T>): c is GroupColumnDef<T> {
|
export function isGroup<T>(c: ColumnDef<T>): c is GroupColumnDef<T> {
|
||||||
return (c as GroupColumnDef<T>).children !== undefined;
|
return (c as GroupColumnDef<T>).children !== undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export function useTableFilters<T>() {
|
|||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
rawQuery.trim().length > 0 ||
|
rawQuery.trim().length > 0 ||
|
||||||
sort !== null ||
|
sort !== null ||
|
||||||
(Object.values(columnFilters) as Array<Array<string | number> | undefined>).some((v) => v && v.length > 0);
|
(Object.values(columnFilters) as ((string | number)[] | undefined)[]).some((v) => v && v.length > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rawQuery,
|
rawQuery,
|
||||||
|
|||||||
@ -40,8 +40,7 @@ createRoot(rootElement).render(
|
|||||||
headerBg: 'rgb(248, 250, 252)',
|
headerBg: 'rgb(248, 250, 252)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<App />
|
<App />
|
||||||
</ConfigProvider>,
|
</ConfigProvider>,
|
||||||
// </StrictMode>,
|
// </StrictMode>,
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export function parseAmountInput(input: string): { ok: true; value: string | nul
|
|||||||
|
|
||||||
/** Точная сумма строк Decimal из API (копейками, без float) → «123.45»; пустые пропускаются, все пустые — null.
|
/** Точная сумма строк Decimal из API (копейками, без float) → «123.45»; пустые пропускаются, все пустые — null.
|
||||||
* Сумма, которую не удалось разобрать (больше двух знаков, текст), делает итог null — итог не выдумываем. */
|
* Сумма, которую не удалось разобрать (больше двух знаков, текст), делает итог null — итог не выдумываем. */
|
||||||
export function sumAmounts(values: ReadonlyArray<string | null | undefined>): string | null {
|
export function sumAmounts(values: readonly (string | null | undefined)[]): string | null {
|
||||||
let cents = 0n;
|
let cents = 0n;
|
||||||
let any = false;
|
let any = false;
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
|
|||||||
@ -20,13 +20,7 @@ const ITEMS = [
|
|||||||
{},
|
{},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const RowActions = memo(function RowActions({
|
const RowActions = memo(function RowActions({ row, onAction }: { row: ConsRow; onAction: (key: ActionKey, _row: ConsRow) => void }) {
|
||||||
row,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
row: ConsRow;
|
|
||||||
onAction: (key: ActionKey, _row: ConsRow) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export function flattenCons(cons: IConsolidation[]): ConsRow[] {
|
|||||||
for (const con of cons) {
|
for (const con of cons) {
|
||||||
// if a con has no versions, emit nothing — or emit a placeholder row if you want
|
// if a con has no versions, emit nothing — or emit a placeholder row if you want
|
||||||
con.versions.forEach((v, i) => {
|
con.versions.forEach((v, i) => {
|
||||||
const rep_date = new Date(con.reporting_date) ?? null;
|
const rep_date = new Date(con.reporting_date);
|
||||||
out.push({
|
out.push({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
group_id: con.consolidation_id,
|
group_id: con.consolidation_id,
|
||||||
|
|||||||
@ -6,7 +6,11 @@ export function useGrouping(rows: ConsRow[]) {
|
|||||||
const toggle = (group_id: string) =>
|
const toggle = (group_id: string) =>
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.has(group_id) ? next.delete(group_id) : next.add(group_id);
|
if (next.has(group_id)) {
|
||||||
|
next.delete(group_id);
|
||||||
|
} else {
|
||||||
|
next.add(group_id);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
const visibleRows = useMemo(() => rows.filter((r) => r.isPrimary || expanded.has(r.group_id)), [rows, expanded]);
|
const visibleRows = useMemo(() => rows.filter((r) => r.isPrimary || expanded.has(r.group_id)), [rows, expanded]);
|
||||||
|
|||||||
@ -37,7 +37,12 @@ function DropdownShell({
|
|||||||
onReset,
|
onReset,
|
||||||
close,
|
close,
|
||||||
children,
|
children,
|
||||||
}: { onApply: () => void; onReset: () => void; close: () => void; children: React.ReactNode }) {
|
}: {
|
||||||
|
onApply: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
close: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8, width: 260 }}>
|
<div style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8, width: 260 }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
|
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Config } from '../../conf/config';
|
||||||
import { pdfjsLib } from './pdfjs';
|
import { pdfjsLib } from './pdfjs';
|
||||||
|
import { pdfjsWasmUrl } from './pdfjsOptions';
|
||||||
|
|
||||||
export const MIN_ZOOM = 0.5;
|
export const MIN_ZOOM = 0.5;
|
||||||
export const MAX_ZOOM = 3;
|
export const MAX_ZOOM = 3;
|
||||||
@ -22,7 +24,7 @@ export default function PdfPages({ data, zoom }: { data: ArrayBuffer; zoom: numb
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
// pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно)
|
// pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно)
|
||||||
pdf = await pdfjsLib.getDocument({ data: data.slice(0) }).promise;
|
pdf = await pdfjsLib.getDocument({ data: data.slice(0), wasmUrl: pdfjsWasmUrl(Config.APP_ADDRESS) }).promise;
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
pdf.destroy();
|
pdf.destroy();
|
||||||
return;
|
return;
|
||||||
|
|||||||
17
web/src/widgets/DocumentReview/pdfjsOptions.test.ts
Normal file
17
web/src/widgets/DocumentReview/pdfjsOptions.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { PDFJS_WASM_DIR, pdfjsWasmUrl } from './pdfjsOptions';
|
||||||
|
|
||||||
|
describe('pdfjsWasmUrl', () => {
|
||||||
|
it('кладёт wasm под префикс приложения и заканчивается слэшем (pdf.js дописывает к нему имя файла)', () => {
|
||||||
|
expect(pdfjsWasmUrl('/aurora/apps/fastapi-dkb-armka')).toBe(`/aurora/apps/fastapi-dkb-armka/${PDFJS_WASM_DIR}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не удваивает слэши, если префикс уже заканчивается слэшем', () => {
|
||||||
|
expect(pdfjsWasmUrl('/aurora/apps/fastapi-dkb-armka/')).toBe(`/aurora/apps/fastapi-dkb-armka/${PDFJS_WASM_DIR}/`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при корневом префиксе даёт путь от корня', () => {
|
||||||
|
expect(pdfjsWasmUrl('/')).toBe(`/${PDFJS_WASM_DIR}/`);
|
||||||
|
expect(pdfjsWasmUrl('')).toBe(`/${PDFJS_WASM_DIR}/`);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
web/src/widgets/DocumentReview/pdfjsOptions.ts
Normal file
14
web/src/widgets/DocumentReview/pdfjsOptions.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// Опции pdf.js для getDocument. Чистый модуль (без импорта pdfjs-dist), чтобы его можно было тестировать.
|
||||||
|
//
|
||||||
|
// pdf.js 5.x декодирует сканы JBIG2 и JPEG2000 и цветовые профили ICC через wasm-файлы (jbig2.wasm, openjpeg.wasm,
|
||||||
|
// qcms_bg.wasm). Без опции wasmUrl он их не находит: «JBig2 failed to initialize», «Unable to decode image», страница
|
||||||
|
// остаётся пустой (AURORA-1635). Файлы копирует в сборку rsbuild.config.ts (output.copy) в каталог PDFJS_WASM_DIR.
|
||||||
|
|
||||||
|
/** Каталог wasm-файлов pdf.js в собранном приложении (относительно корня приложения). Тот же путь — в rsbuild.config.ts. */
|
||||||
|
export const PDFJS_WASM_DIR = 'static/pdfjs/wasm';
|
||||||
|
|
||||||
|
/** Адрес каталога wasm: с учётом префикса приложения (ROOT_PATH) и обязательным слэшем в конце — pdf.js дописывает к нему имя файла. */
|
||||||
|
export function pdfjsWasmUrl(appAddress: string): string {
|
||||||
|
const base = appAddress.replace(/\/+$/, '');
|
||||||
|
return `${base}/${PDFJS_WASM_DIR}/`;
|
||||||
|
}
|
||||||
@ -138,13 +138,7 @@ function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Section = memo(function Section({
|
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
|
||||||
isCollapsed,
|
|
||||||
group,
|
|
||||||
}: {
|
|
||||||
isCollapsed: boolean;
|
|
||||||
group: Group;
|
|
||||||
}) {
|
|
||||||
return !isCollapsed ? (
|
return !isCollapsed ? (
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>{group.title}</div>
|
<div className={styles.sectionTitle}>{group.title}</div>
|
||||||
|
|||||||
@ -30,7 +30,9 @@
|
|||||||
color: rgba(55, 65, 81, 0.88);
|
color: rgba(55, 65, 81, 0.88);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: background 0.2s, color 0.2s;
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .linkActive:hover {
|
/* .linkActive:hover {
|
||||||
@ -72,7 +74,9 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: rgba(0, 0, 0, 0.75);
|
||||||
transition: background 0.2s, color 0.2s;
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +115,9 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: rgba(0, 0, 0, 0.75);
|
||||||
transition: background 0.2s, color 0.2s;
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user