Форматирование перед PR: ESLint + Prettier и npm run format (#29)

- Prettier — единственный форматтер (табы, 140, одинарные кавычки, в CSS — двойные);
  форматтер biome выключен, biome остаётся линтером и сортирует импорты.
- ESLint (flat config): js/typescript-eslint/react-hooks recommended + правила biome.json,
  eslint-config-prettier последним.
- npm run format / make format: eslint --fix → biome check --write → prettier --write.
- npm run lint: eslint + prettier --check; входит в make lint и make check.
- Разовое переформатирование 4 файлов, `(A | B)[]` вместо Array<…> в 3 местах,
  две находки ESLint в ConsoleTable (лишний `?? null`, тернарник-инструкция).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 17:15:14 +04:00
parent 100fbfb899
commit a8a6da0de3
17 changed files with 125 additions and 35 deletions

16
.prettierrc.json Normal file
View 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 }
}
]
}

View File

@ -26,7 +26,8 @@ SPA кредитного аналитика АРМ КА: справочники
## Стек и ограничения ## Стек и ограничения
- React 18 + TypeScript strict, rsbuild, antd 6 (основной UI-кит; daisyui/tailwind — из шаблона), react-router 7, - React 18 + TypeScript strict, rsbuild, antd 6 (основной UI-кит; daisyui/tailwind — из шаблона), react-router 7,
Redux Toolkit, pdfjs-dist (только legacy-сборка через `widgets/DocumentReview/pdfjs.ts`: обычная падает в Яндекс Браузере, Redux Toolkit, pdfjs-dist (только legacy-сборка через `widgets/DocumentReview/pdfjs.ts`: обычная падает в Яндекс Браузере,
#22); линтер — biome (ширина 140, одинарные кавычки). #22); линтеры — biome и ESLint (правила повторяют друг друга), форматтер — только Prettier (ширина 140, табы,
одинарные кавычки; форматтер biome выключен).
- Пакеты `raisa-*` — `optionalDependencies` (вне банка недоступны); `package-lock.json` в git не хранится (как в шаблоне: - Пакеты `raisa-*` — `optionalDependencies` (вне банка недоступны); `package-lock.json` в git не хранится (как в шаблоне:
в банке зависимости ставятся через Nexus). в банке зависимости ставятся через Nexus).
- env только `REACT_APP_*` через `src/conf/config.ts`; `REACT_APP_API_URL` — адрес arm-core, `REACT_APP_ROOT_PATH` = - env только `REACT_APP_*` через `src/conf/config.ts`; `REACT_APP_API_URL` — адрес arm-core, `REACT_APP_ROOT_PATH` =
@ -47,7 +48,9 @@ SPA кредитного аналитика АРМ КА: справочники
## Команды ## Команды
- `make install` — `npm install`. - `make install` — `npm install`.
- `make test` — vitest (`npm test`). - `make test` — vitest (`npm test`).
- `make check` — install + biome + `tsc --noEmit` + vitest + `rsbuild build`. Зелёным считается только всё вместе; **перед PR**, вывод — - `make format` / `npm run format` — `eslint --fix` → `biome check --write` → `prettier --write` по `src/`; перед PR.
- `make lint` — `biome check src` + `npm run lint` (ESLint + `prettier --check`).
- `make check` — install + lint (biome, ESLint, Prettier) + `tsc --noEmit` + vitest + `rsbuild build`. Зелёным считается только всё вместе; **перед PR**, вывод —
в описание PR. в описание PR.
- `make start` — dev-сервер (`rsbuild dev`, цель сама задаёт `REACT_APP_ROOT_PATH=/`: без него приложение ждёт префикс - `make start` — dev-сервер (`rsbuild dev`, цель сама задаёт `REACT_APP_ROOT_PATH=/`: без него приложение ждёт префикс
`/aurora/apps/fastapi-dkb-armka` и открывается белый экран; `qa-guide` §4.3) с proxy `/api` → `ARM_CORE_URL` (по умолчанию `http://localhost:8000`); бэкенд — локальный стенд arm-core по `../docs/dev/qa-guide.md`. `/aurora/apps/fastapi-dkb-armka` и открывается белый экран; `qa-guide` §4.3) с proxy `/api` → `ARM_CORE_URL` (по умолчанию `http://localhost:8000`); бэкенд — локальный стенд arm-core по `../docs/dev/qa-guide.md`.

View File

@ -1,10 +1,15 @@
.PHONY: install lint typecheck test build check start .PHONY: install format lint typecheck test build check start
install: install:
npm install npm install
# Перед PR: eslint --fix → biome --write → prettier --write
format:
npm run format
lint: lint:
npx biome check src npx biome check src
npm run lint
typecheck: typecheck:
npx tsc --noEmit npx tsc --noEmit

View File

@ -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`. Зелёным
считается всё вместе.
## Демо-сценарий ## Демо-сценарий

View File

@ -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
eslint.config.js Normal file
View 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,
);

View File

@ -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": {

View File

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

View File

@ -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,

View File

@ -40,8 +40,7 @@ createRoot(rootElement).render(
headerBg: 'rgb(248, 250, 252)', headerBg: 'rgb(248, 250, 252)',
}, },
}, },
}} }}>
>
<App /> <App />
</ConfigProvider>, </ConfigProvider>,
// </StrictMode>, // </StrictMode>,

View File

@ -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) {

View File

@ -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']}

View File

@ -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,

View File

@ -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]);

View File

@ -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}

View File

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

View File

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