From 2c02d15a100660e7c2cf789b8bd4d0e70d8770ed Mon Sep 17 00:00:00 2001 From: vladtechno Date: Thu, 30 Jul 2026 11:53:02 +1000 Subject: [PATCH] =?UTF-8?q?repairs(pwa):=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=B8/=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0/?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=82=D0=B0=20=D1=81=20=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D1=8C=D0=B3=D0=B0=D0=BC=D0=B8=20+=20=D1=81=D0=BB=D0=B8=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=20=D1=81=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод mechanic-pwa собирался с ветки feat/driver-pwa, а main отставал на 18 коммитов (24.05→10.06). Сборка от 29.07 ушла в прод с main — из приложения пропали разделы. Вернул содержимое ветки и заново наложил работу 29.07. Возвращено: - лента: сумма за месяц, разделители по дням с итогом дня, цена в строке, бейдж «с улицы»; - карточка ремонта /repairs/:id (ссылка из ленты вела в никуда); - правка ремонта /repairs/:id/edit (окно 1 час); - ремонт «с улицы» /repairs/new-external; - блок «Механики» — делёж оплаты 1/N; - vitest-харнесс и вынесенные в lib/ тестируемые хелперы. Поверх наложено (работа 29.07): несколько замен масла за заезд, запчасти из складского каталога вместо свободного текста. Дополнительно: - пикер деталей вынесен в общий PartsPickerModal (создание + правка). Без этого правка ремонта отправляла детали без part_id и сверка склада возвращала всё списанное на остаток; - форма правки знает про 1:N масла — раньше схлопнула бы вторую запись; - карточка ремонта показывает артикул детали и все замены масла. Co-Authored-By: Claude Opus 4.8 --- mechanic-pwa/frontend/package-lock.json | 1346 ++++++++++++++++- mechanic-pwa/frontend/package.json | 12 +- mechanic-pwa/frontend/src/App.tsx | 5 + .../frontend/src/api/repairsCreate.ts | 33 +- mechanic-pwa/frontend/src/api/repairsFeed.ts | 120 ++ .../src/components/repairs/MechanicsField.tsx | 168 ++ .../components/repairs/PartsPickerModal.tsx | 133 ++ .../repairs/WorksPickerModal.test.tsx | 43 + .../components/repairs/WorksPickerModal.tsx | 74 + mechanic-pwa/frontend/src/index.css | 24 + .../frontend/src/lib/inspectionSlots.test.ts | 18 + .../frontend/src/lib/inspectionSlots.ts | 37 + .../frontend/src/lib/repairForm.test.ts | 50 + mechanic-pwa/frontend/src/lib/repairForm.ts | 37 + .../frontend/src/lib/repairsFeed.test.ts | 97 ++ mechanic-pwa/frontend/src/lib/repairsFeed.ts | 81 + .../frontend/src/pages/InspectionEditor.tsx | 22 +- .../src/pages/repairs/CarSelectPage.test.tsx | 51 + .../src/pages/repairs/CarSelectPage.tsx | 51 + .../src/pages/repairs/CreateRepairPage.tsx | 398 +++-- .../src/pages/repairs/EditRepairPage.tsx | 614 ++++++++ .../src/pages/repairs/RepairDetailPage.tsx | 349 +++++ .../src/pages/repairs/RepairsFeedPage.tsx | 91 +- mechanic-pwa/frontend/src/test/setup.ts | 1 + mechanic-pwa/frontend/src/test/smoke.test.tsx | 6 + mechanic-pwa/frontend/tsconfig.app.json | 3 +- mechanic-pwa/frontend/vitest.config.ts | 20 + 27 files changed, 3619 insertions(+), 265 deletions(-) create mode 100644 mechanic-pwa/frontend/src/components/repairs/MechanicsField.tsx create mode 100644 mechanic-pwa/frontend/src/components/repairs/PartsPickerModal.tsx create mode 100644 mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx create mode 100644 mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx create mode 100644 mechanic-pwa/frontend/src/lib/inspectionSlots.test.ts create mode 100644 mechanic-pwa/frontend/src/lib/inspectionSlots.ts create mode 100644 mechanic-pwa/frontend/src/lib/repairForm.test.ts create mode 100644 mechanic-pwa/frontend/src/lib/repairForm.ts create mode 100644 mechanic-pwa/frontend/src/lib/repairsFeed.test.ts create mode 100644 mechanic-pwa/frontend/src/lib/repairsFeed.ts create mode 100644 mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.test.tsx create mode 100644 mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx create mode 100644 mechanic-pwa/frontend/src/pages/repairs/RepairDetailPage.tsx create mode 100644 mechanic-pwa/frontend/src/test/setup.ts create mode 100644 mechanic-pwa/frontend/src/test/smoke.test.tsx create mode 100644 mechanic-pwa/frontend/vitest.config.ts diff --git a/mechanic-pwa/frontend/package-lock.json b/mechanic-pwa/frontend/package-lock.json index e4d5d42..6d8916c 100644 --- a/mechanic-pwa/frontend/package-lock.json +++ b/mechanic-pwa/frontend/package-lock.json @@ -27,22 +27,35 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.8", "autoprefixer": "^10.5.0", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^3.4.19", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.8" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -56,6 +69,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1507,6 +1571,169 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1666,6 +1893,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2628,6 +2873,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.100.10", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", @@ -2654,6 +2906,96 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -2679,6 +3021,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3010,6 +3378,160 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3049,6 +3571,31 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -3090,6 +3637,16 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3127,6 +3684,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -3263,6 +3859,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3417,6 +4023,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3546,6 +4162,27 @@ "node": ">=8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3566,6 +4203,58 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3634,6 +4323,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3684,6 +4380,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3707,6 +4413,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3742,6 +4456,19 @@ "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3828,6 +4555,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4093,6 +4827,16 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4571,6 +5315,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -4654,6 +5408,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -4680,6 +5454,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4956,6 +5740,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5125,6 +5916,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -5173,6 +6003,95 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5599,6 +6518,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5608,6 +6538,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5617,6 +6588,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5654,6 +6632,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5798,6 +6786,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5871,6 +6873,19 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5921,6 +6936,13 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6152,6 +7174,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6219,6 +7257,14 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.15.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", @@ -6293,6 +7339,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6588,6 +7648,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6757,6 +7830,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6829,6 +7909,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6948,6 +8042,19 @@ "node": ">=10" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -6981,6 +8088,19 @@ "node": ">= 6" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -6993,6 +8113,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -7109,6 +8236,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -7125,6 +8269,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7138,6 +8312,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -7329,6 +8516,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7561,12 +8758,125 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -7678,6 +8988,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7929,6 +9256,23 @@ "workbox-core": "7.4.1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/mechanic-pwa/frontend/package.json b/mechanic-pwa/frontend/package.json index fdf2497..ffd3281 100644 --- a/mechanic-pwa/frontend/package.json +++ b/mechanic-pwa/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@radix-ui/react-label": "^2.1.8", @@ -29,19 +31,25 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.8", "autoprefixer": "^10.5.0", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^3.4.19", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.8" } } diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index 35982c6..9ab210c 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -9,6 +9,8 @@ import InspectionEditor from "@/pages/InspectionEditor"; import InspectionReview from "@/pages/InspectionReview"; import TtStateDetail from "@/pages/TtStateDetail"; import RepairsFeedPage from "@/pages/repairs/RepairsFeedPage"; +import RepairDetailPage from "@/pages/repairs/RepairDetailPage"; +import EditRepairPage from "@/pages/repairs/EditRepairPage"; import CarSelectPage from "@/pages/repairs/CarSelectPage"; import CreateRepairPage from "@/pages/repairs/CreateRepairPage"; @@ -37,6 +39,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> ); diff --git a/mechanic-pwa/frontend/src/api/repairsCreate.ts b/mechanic-pwa/frontend/src/api/repairsCreate.ts index 1036889..6a351bf 100644 --- a/mechanic-pwa/frontend/src/api/repairsCreate.ts +++ b/mechanic-pwa/frontend/src/api/repairsCreate.ts @@ -79,14 +79,38 @@ export interface CreateOilChangePayload { sticker_extension: string | null; } +export interface ExternalCarPayload { + plate: string; + make: string | null; +} + export interface CreateRepairPayload { - car_id: number; + car_id?: number; + external_car?: ExternalCarPayload; mileage: number | null; works: CreateWorkPayload[]; parts: CreatePartPayload[]; photos: CreatePhotoPayload[]; // 1:N — за один заезд бывает несколько замен (двигатель + коробка). + // Пришло на смену одиночному oil_change; бэкенд принимает оба, но новый + // клиент шлёт только список. oil_changes: CreateOilChangePayload[]; + // Работавшие механики (делёж 1/N). Если не передан — backend сидит + // одного создателя. Порядок = sort_order. + mechanic_ids?: number[]; +} + +// ── Ростер механиков (пикер «Механики») ──────────────────────────────────── + +export interface MechanicRosterItem { + id: number; + full_name: string; +} + +export async function getMechanicsRoster(): Promise { + // prefixUrl = /api/v1/mechanic, эндпоинт под роутером /v1/mechanic/repairs → + // путь обязан включать сегмент repairs/ (как и остальные вызовы файла). + return api.get("repairs/mechanics-roster").json(); } export interface CreateRepairResponse { @@ -122,10 +146,13 @@ export async function suggestParts(q: string): Promise { */ export async function searchParts( q: string, - carId: number, + carId: number | null, showAll = false, ): Promise { - const params = new URLSearchParams({ car_id: String(carId) }); + const params = new URLSearchParams(); + // Без машины (ремонт «с улицы») применимость считать не по чему — + // бэкенд отдаст каталог целиком. + if (carId) params.set("car_id", String(carId)); if (q.trim()) params.set("q", q.trim()); if (showAll) params.set("all", "true"); return api.get(`parts/search?${params.toString()}`).json(); diff --git a/mechanic-pwa/frontend/src/api/repairsFeed.ts b/mechanic-pwa/frontend/src/api/repairsFeed.ts index 2155c9b..ee22a86 100644 --- a/mechanic-pwa/frontend/src/api/repairsFeed.ts +++ b/mechanic-pwa/frontend/src/api/repairsFeed.ts @@ -12,8 +12,10 @@ export interface FeedItem { car_model: string | null; driver_name: string | null; mechanic_name: string; + is_external: boolean; works_preview: FeedWorkBrief[]; works_extra: number; + works_total: number; // сумма работ без запчастей photo_url: string | null; } @@ -36,3 +38,121 @@ export async function getRepairsFeed(params: FeedParams = {}): Promise(); } + +export interface RepairsMonthStats { + total: number; + count: number; +} + +export async function getRepairsMonthStats(): Promise { + return api.get("repairs/stats/month").json(); +} + +// ── Детальная карточка ремонта ───────────────────────────────────────────── + +export interface RepairDetailWork { + id: number; + work_catalog_id: number; + name: string; + price_applied: number; + manual_override: boolean; + override_comment: string | null; +} + +export interface RepairDetailPart { + id: number; + name: string; + qty: number; + // Складская позиция. null у исторических строк, заведённых свободным + // текстом до перевода запчастей на каталог — их склад не трогает. + part_id: number | null; + article: string | null; +} + +export interface RepairDetailPhoto { + photo_uuid: string; + thumb_url: string | null; + orig_url: string | null; +} + +export interface RepairDetailOil { + mileage_at_change: number | null; + next_in_km: number | null; + oil_type: string; // engine | cvt | akpp + has_sticker: boolean; + sticker_thumb_url: string | null; + sticker_orig_url: string | null; +} + +export interface RepairDetailMechanic { + id: number; + mechanic_id: number; + name: string; +} + +export interface RepairDetailResponse { + id: number; + created_at: string; + // Машина парка; null у ремонта «с улицы». + car_id: number | null; + car_plate: string | null; + car_make: string | null; + car_model: string | null; + driver_name: string | null; + mechanic_name: string | null; + mileage: number | null; + works: RepairDetailWork[]; + parts: RepairDetailPart[]; + photos: RepairDetailPhoto[]; + oil_change: RepairDetailOil | null; // legacy: первая запись + oil_changes: RepairDetailOil[]; // 1:N — двигатель + коробка за заезд + total_sum: number; + // Работавшие механики (делёж 1/N total_sum). Может отсутствовать на + // старых ответах backend — фронт делает fallback на одного создателя. + mechanics?: RepairDetailMechanic[]; + edited_in_crm: boolean; + can_edit: boolean; + edit_until: string | null; +} + +export async function getRepairDetail(id: number): Promise { + return api.get(`repairs/${id}`).json(); +} + +// ── PATCH (правка в 1-часовом окне У49) ──────────────────────────────────── + +export interface PatchWork { + work_catalog_id: number; + price_applied: number; + manual_override: boolean; + override_comment: string | null; +} + +export interface PatchPart { + // Складская позиция. Без part_id строка остаётся свободным текстом и склад + // не трогает (так сохраняются исторические строки). + part_id?: number | null; + name: string; + qty: number; +} + +export interface PatchOilChange { + enabled: boolean; + mileage_at_change: number | null; + next_in_km: number | null; + oil_type?: string; // engine | transmission | cvt | akpp +} + +export interface PatchRepairPayload { + mileage: number | null; + works: PatchWork[]; + parts: PatchPart[]; + // Полная замена набора замен масла за заезд (пустой список = замен не было). + oil_changes: PatchOilChange[]; + // Работавшие механики. Опционально — если не передан, набор не меняется. + mechanic_ids?: number[]; +} + +export async function patchRepair(id: number, body: PatchRepairPayload): Promise { + return api.patch(`repairs/${id}`, { json: body }).json(); +} diff --git a/mechanic-pwa/frontend/src/components/repairs/MechanicsField.tsx b/mechanic-pwa/frontend/src/components/repairs/MechanicsField.tsx new file mode 100644 index 0000000..22bcf68 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/MechanicsField.tsx @@ -0,0 +1,168 @@ +/** + * Блок «Механики» формы ремонта — чипы работавших механиков + кнопка + * «Добавить механика» (пикер из ростера) + удаление чипа. + * + * Делёж оплаты — поровну (1/N): доля каждого механика = total_sum / N. + * Здесь показывается информативно (сумма на чел.), если передан totalSum. + * + * Состояние выбора живёт в родителе (как у WorksPickerModal): сюда + * приходит value + onChange. Ростер компонент тянет сам при первом + * открытии пикера (лениво) и кэширует на время жизни. + */ +import { useState } from "react"; +import { Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { fmtMoney } from "@/lib/repairsFeed"; +import { getMechanicsRoster, type MechanicRosterItem } from "@/api/repairsCreate"; + +export interface SelectedMechanic { + mechanic_id: number; + name: string; +} + +interface Props { + value: SelectedMechanic[]; + onChange: (next: SelectedMechanic[]) => void; + // Если задан и механиков >0 — показываем долю (1/N · totalSum). + totalSum?: number; + disabled?: boolean; +} + +export default function MechanicsField({ value, onChange, totalSum, disabled }: Props) { + const [pickerOpen, setPickerOpen] = useState(false); + const [roster, setRoster] = useState(null); + const [rosterError, setRosterError] = useState(null); + const [q, setQ] = useState(""); + + const openPicker = async () => { + setPickerOpen(true); + if (roster) return; + try { + const list = await getMechanicsRoster(); + setRoster(list); + setRosterError(null); + } catch (e) { + setRosterError((e as Error).message || "Не удалось загрузить список механиков"); + } + }; + + const addMechanic = (m: MechanicRosterItem) => { + // Дедуп: один механик один раз (UNIQUE на бэке). + if (!value.some((v) => v.mechanic_id === m.id)) { + onChange([...value, { mechanic_id: m.id, name: m.full_name }]); + } + setPickerOpen(false); + setQ(""); + }; + + const removeMechanic = (mechanicId: number) => + onChange(value.filter((v) => v.mechanic_id !== mechanicId)); + + const share = + totalSum != null && value.length > 0 ? Math.round(totalSum / value.length) : null; + + const filtered = (roster ?? []).filter((m) => + q.trim() ? m.full_name.toLowerCase().includes(q.trim().toLowerCase()) : true, + ); + + return ( +
+
+ + {!disabled && ( + + )} +
+ + {value.length === 0 ? ( +
Минимум один механик
+ ) : ( +
+ {value.map((m) => ( + + {m.name} + {!disabled && ( + + )} + + ))} +
+ )} + + {share != null && value.length > 1 && ( +
+ Делёж поровну: {fmtMoney(share)} ₽ на каждого ({value.length} мех.) +
+ )} + + {/* Пикер механиков из ростера */} + {pickerOpen && ( +
+
+ + setQ(e.target.value)} + /> +
+
+ {rosterError && ( +
+ {rosterError} +
+ )} + {roster === null && !rosterError && ( +
Загрузка…
+ )} + {filtered.map((m) => { + const already = value.some((v) => v.mechanic_id === m.id); + return ( + + ); + })} + {roster !== null && !rosterError && filtered.length === 0 && ( +
+ Ничего не найдено +
+ )} +
+
+ )} +
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/repairs/PartsPickerModal.tsx b/mechanic-pwa/frontend/src/components/repairs/PartsPickerModal.tsx new file mode 100644 index 0000000..61821a4 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/PartsPickerModal.tsx @@ -0,0 +1,133 @@ +/** + * Пикер детали из складского каталога — общий для формы создания и формы + * правки ремонта (по образцу WorksPickerModal: состояние поиска живёт внутри, + * наружу отдаётся только выбранная позиция). + * + * Свободного ввода нет намеренно: деталь ремонта — это реальная складская + * позиция, которая списывается с остатка. Нет в каталоге → сначала карточка + * на складе. + * + * По умолчанию показываем только то, что подходит этой машине ИЛИ вообще не + * привязано к марке; «Показать все» снимает фильтр (страховка, пока + * применимость не набита). У ремонта «с улицы» машины парка нет — carId=null, + * и бэкенд отдаёт каталог целиком. + */ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { searchParts, type PartCatalogItem } from "@/api/repairsCreate"; + +interface Props { + open: boolean; + carId: number | null; + onPick: (item: PartCatalogItem) => void; + onClose: () => void; +} + +export default function PartsPickerModal({ open, carId, onPick, onClose }: Props) { + const [q, setQ] = useState(""); + const [showAll, setShowAll] = useState(false); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + + const load = async (query: string, all: boolean) => { + setLoading(true); + try { + setResults(await searchParts(query, carId, all)); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }; + + // Открытие = свежий поиск с чистого листа: прошлый запрос механика к этому + // моменту уже неактуален, а его остатки на экране читаются как «не нашлось». + useEffect(() => { + if (!open) return; + setQ(""); + setShowAll(false); + void load("", false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, carId]); + + if (!open) return null; + + const toggleShowAll = () => { + const next = !showAll; + setShowAll(next); + void load(q, next); + }; + + return ( +
+
+ + { + setQ(e.target.value); + void load(e.target.value, showAll); + }} + /> +
+ {carId != null && ( +
+ + {showAll ? "Все детали каталога" : "Подходящие этой машине"} + + +
+ )} +
+
    + {results.map((p) => ( +
  • + +
  • + ))} + {!loading && results.length === 0 && ( +
  • + {q + ? "Ничего не найдено. Если детали нет в каталоге — заведите карточку на складе." + : "Нет подходящих деталей. Нажмите «Показать все» или заведите карточку на складе."} +
  • + )} + {loading &&
  • Загрузка…
  • } +
+
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx new file mode 100644 index 0000000..c5ed579 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import WorksPickerModal from "@/components/repairs/WorksPickerModal"; +import type { MechWork } from "@/api/repairsCreate"; + +const works: MechWork[] = [ + { id: 1, name: "Замена масла", category_id: 1, price: 1500, norma_hours: null }, + { id: 2, name: "Развал-схождение", category_id: 1, price: "2000", norma_hours: null }, +]; + +test("рендерит работы с ценой и зовёт onPick по клику", async () => { + const user = userEvent.setup(); + const onPick = vi.fn(); + render( + {}} onPick={onPick} onClose={() => {}} />, + ); + expect(screen.getByText("Замена масла")).toBeInTheDocument(); + expect(screen.getByText("1 500 ₽")).toBeInTheDocument(); // число из 1500 + expect(screen.getByText("2 000 ₽")).toBeInTheDocument(); // строка "2000" → число + await user.click(screen.getByText("Развал-схождение")); + expect(onPick).toHaveBeenCalledWith(works[1]); +}); + +test("пустой список → «Ничего не найдено»", () => { + render( + {}} onPick={() => {}} onClose={() => {}} />, + ); + expect(screen.getByText("Ничего не найдено")).toBeInTheDocument(); +}); + +test("ввод в поиск зовёт onQuery; кнопка назад — onClose", async () => { + const user = userEvent.setup(); + const onQuery = vi.fn(); + const onClose = vi.fn(); + render( + {}} onClose={onClose} />, + ); + await user.type(screen.getByPlaceholderText("Поиск работы…"), "масло"); + expect(onQuery).toHaveBeenCalled(); + await user.click(screen.getByLabelText("Закрыть")); + expect(onClose).toHaveBeenCalled(); +}); diff --git a/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx new file mode 100644 index 0000000..c547b3b --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx @@ -0,0 +1,74 @@ +/** + * Модалка выбора работы из каталога. Раньше была продублирована почти один-в-один + * в CreateRepairPage и EditRepairPage — вынесена в общий презентационный компонент. + * + * Состояние (открыта/запрос/результаты) живёт в родителе; здесь только UI + + * колбэки. Это сознательно: формы создания и правки ремонта разошлись по UX + * (inline-разблокировка цены против модалки-override, фото против их отсутствия), + * поэтому единая «форма ремонта» была бы хуже — общим является именно этот пикер. + */ +import { ChevronLeft } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { fmtMoney } from "@/lib/repairsFeed"; +import type { MechWork } from "@/api/repairsCreate"; + +interface Props { + query: string; + results: MechWork[]; + onQuery: (q: string) => void; + onPick: (work: MechWork) => void; + onClose: () => void; +} + +export default function WorksPickerModal({ query, results, onQuery, onPick, onClose }: Props) { + return ( +
+
+ + onQuery(e.target.value)} + /> +
+
+ {results.map((mw) => ( + + ))} + {results.length === 0 && ( +
+ Ничего не найдено +
+ )} +
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/index.css b/mechanic-pwa/frontend/src/index.css index 90fffd9..bd4570f 100644 --- a/mechanic-pwa/frontend/src/index.css +++ b/mechanic-pwa/frontend/src/index.css @@ -31,5 +31,29 @@ * { @apply border-border; } body { @apply bg-background text-foreground font-sans; + /* iOS PWA: status bar (status-bar-style=black-translucent) и home-indicator + перекрывают контент при viewport-fit=cover. Сдвигаем body content вниз + на высоту safe-area, чтобы шапки/кнопки не уезжали под системные зоны. */ + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + } +} + +@layer utilities { + /* iOS PWA: position:sticky + top:0 прилипает к самому верху viewport, + т.е. под статус-бар (containing-block тут не помогает). Переопределяем + top у всех Tailwind .top-0, чтобы шапки залипали ниже notch. */ + .sticky.top-0, + .fixed.top-0 { + top: env(safe-area-inset-top); + } + /* Full-screen page-replacement модалки (fixed inset-0 с app background) — + поиск работ, аннотация фото, zone-detail, inspection-editor. Шапка внутри + должна сидеть ниже status-bar, нижние кнопки выше home-indicator. + bg-black/40·50·90 диалоги/просмотрщики не задеваем — у них inset-0 full-bleed. */ + .fixed.inset-0.bg-background, + .fixed.inset-0.bg-background\/95 { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); } } diff --git a/mechanic-pwa/frontend/src/lib/inspectionSlots.test.ts b/mechanic-pwa/frontend/src/lib/inspectionSlots.test.ts new file mode 100644 index 0000000..b519fe4 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/inspectionSlots.test.ts @@ -0,0 +1,18 @@ +import { slotsForType } from "@/lib/inspectionSlots"; + +test("Салон перед (ключ interior сохранён) + Салон зад + Багажник", () => { + const slots = slotsForType("handover"); + expect(slots.find((s) => s.side === "interior")?.label).toBe("Салон перед"); + expect(slots.find((s) => s.side === "interior-rear")?.label).toBe("Салон зад"); + expect(slots.find((s) => s.side === "trunk")?.label).toBe("Багажник"); +}); + +test("порядок: Салон перед → Салон зад → Багажник идут подряд", () => { + const sides = slotsForType("return").map((s) => s.side); + const i = sides.indexOf("interior"); + expect(sides.slice(i, i + 3)).toEqual(["interior", "interior-rear", "trunk"]); +}); + +test("набор одинаков для всех типов (унифицировано)", () => { + expect(slotsForType("periodic")).toEqual(slotsForType("handover")); +}); diff --git a/mechanic-pwa/frontend/src/lib/inspectionSlots.ts b/mechanic-pwa/frontend/src/lib/inspectionSlots.ts new file mode 100644 index 0000000..2b96edb --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/inspectionSlots.ts @@ -0,0 +1,37 @@ +/** + * Слоты фото осмотра авто. Вынесены из InspectionEditor, чтобы быть покрытыми + * тестами. Все типы осмотров (приёмка/выдача/плановый/ad-hoc) показывают один + * набор — фидбек: общее состояние машины фиксируем всегда. + * + * ВАЖНО: `side` — стабильный ключ слота (часть S3-ключа фото и значение + * PhotoSide на бэке). Подпись (`label`) менять можно, ключ — нет, иначе уже + * снятые фото осиротеют. Поэтому «Салон перед» имеет ключ `interior` (бывш. «Салон»). + */ +export interface InspectionSlot { + side: string; + label: string; +} + +export const SLOTS_GENERAL: InspectionSlot[] = [ + { side: "front", label: "Перед" }, + { side: "rear", label: "Зад" }, + { side: "left", label: "Левый бок" }, + { side: "right", label: "Правый бок" }, + { side: "vin", label: "VIN" }, + { side: "odometer", label: "Одометр" }, + { side: "interior", label: "Салон перед" }, + { side: "interior-rear", label: "Салон зад" }, + { side: "trunk", label: "Багажник" }, + { side: "free", label: "Свободное" }, +]; + +export const SLOT_JACK: InspectionSlot = { side: "jack", label: "Домкрат" }; + +// Шильдик замены масла — отдельный слот при выдаче/приёмке. Фото нужно как для +// сверки текущего пробега со следующим ТО, так и для контроля что водитель не +// клеит свой стикер. +export const SLOT_OIL_STICKER: InspectionSlot = { side: "oil-sticker", label: "Шильдик замены масла" }; + +export function slotsForType(_type: string): InspectionSlot[] { + return [...SLOTS_GENERAL, SLOT_JACK, SLOT_OIL_STICKER]; +} diff --git a/mechanic-pwa/frontend/src/lib/repairForm.test.ts b/mechanic-pwa/frontend/src/lib/repairForm.test.ts new file mode 100644 index 0000000..0e23553 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/repairForm.test.ts @@ -0,0 +1,50 @@ +import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm"; + +const okPhoto = { status: "uploaded" as const }; +const okWork = { unlocked: false, override_comment: "" }; + +describe("repairSubmitWhyDisabled", () => { + it("нет фото → причина про фото", () => { + expect(repairSubmitWhyDisabled([], [okWork])).toBe("Добавьте хотя бы одно фото"); + }); + + it("фото ещё грузится → причина про загрузку", () => { + expect( + repairSubmitWhyDisabled([{ status: "uploading" }], [okWork]), + ).toBe("Дождитесь окончания загрузки фото"); + }); + + it("фото с ошибкой загрузки тоже блокирует", () => { + expect( + repairSubmitWhyDisabled([{ status: "error" }], [okWork]), + ).toBe("Дождитесь окончания загрузки фото"); + }); + + it("нет работ → причина про работы", () => { + expect(repairSubmitWhyDisabled([okPhoto], [])).toBe("Добавьте хотя бы одну работу"); + }); + + it("разблокированная цена без комментария → требует комментарий", () => { + expect( + repairSubmitWhyDisabled([okPhoto], [{ unlocked: true, override_comment: " " }]), + ).toBe("Укажите комментарий к ручной правке цены"); + }); + + it("разблокированная цена с комментарием → ок", () => { + expect( + repairSubmitWhyDisabled([okPhoto], [{ unlocked: true, override_comment: "скидка" }]), + ).toBe(""); + }); + + it("валидная форма → пустая причина", () => { + expect(repairSubmitWhyDisabled([okPhoto], [okWork])).toBe(""); + }); +}); + +describe("repairCanSubmit", () => { + it("= (нет причины блокировки)", () => { + expect(repairCanSubmit([okPhoto], [okWork])).toBe(true); + expect(repairCanSubmit([], [okWork])).toBe(false); + expect(repairCanSubmit([{ status: "uploading" }], [okWork])).toBe(false); + }); +}); diff --git a/mechanic-pwa/frontend/src/lib/repairForm.ts b/mechanic-pwa/frontend/src/lib/repairForm.ts new file mode 100644 index 0000000..7271621 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/repairForm.ts @@ -0,0 +1,37 @@ +/** + * Логика гейтинга сохранения формы ремонта (Create/Edit). Вынесена из + * CreateRepairPage, чтобы быть единым источником правды и покрытой тестами: + * `canSubmit` теперь буквально «нет причины блокировки», без расхождения + * между двумя проверками. + * + * Принимает структурные срезы стейта (только нужные поля), поэтому не зависит + * от конкретных типов страниц. + */ +export interface RepairFormPhotoLike { + status: "uploading" | "uploaded" | "error" | string; +} + +export interface RepairFormWorkLike { + unlocked: boolean; + override_comment: string; +} + +/** Возвращает причину, по которой сохранение заблокировано, или "" если можно. */ +export function repairSubmitWhyDisabled( + photos: RepairFormPhotoLike[], + works: RepairFormWorkLike[], +): string { + if (photos.length === 0) return "Добавьте хотя бы одно фото"; + if (photos.some((p) => p.status !== "uploaded")) return "Дождитесь окончания загрузки фото"; + if (works.length === 0) return "Добавьте хотя бы одну работу"; + const unlockedNoComment = works.find((w) => w.unlocked && !w.override_comment.trim()); + if (unlockedNoComment) return "Укажите комментарий к ручной правке цены"; + return ""; +} + +export function repairCanSubmit( + photos: RepairFormPhotoLike[], + works: RepairFormWorkLike[], +): boolean { + return repairSubmitWhyDisabled(photos, works) === ""; +} diff --git a/mechanic-pwa/frontend/src/lib/repairsFeed.test.ts b/mechanic-pwa/frontend/src/lib/repairsFeed.test.ts new file mode 100644 index 0000000..13ee4b7 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/repairsFeed.test.ts @@ -0,0 +1,97 @@ +import type { FeedItem } from "@/api/repairsFeed"; +import { + toLocalDateKey, + formatDayLabel, + groupByLocalDay, + fmtMoney, + pluralRepairs, + pluralWorks, +} from "@/lib/repairsFeed"; + +function item(id: number, created_at: string, works_total = 0): FeedItem { + return { + id, + created_at, + car_plate: "А001АА125", + car_make: null, + car_model: null, + driver_name: null, + mechanic_name: "M", + works_preview: [], + works_extra: 0, + works_total, + photo_url: null, + is_external: false, + } as FeedItem; +} + +describe("toLocalDateKey (Asia/Vladivostok)", () => { + it("маппит UTC-инстант на VLA-сутки (+10)", () => { + // 2026-05-25 20:00Z == 2026-05-26 06:00 VLA → день 26-го + expect(toLocalDateKey("2026-05-25T20:00:00Z")).toBe("2026-05-26"); + // 2026-05-25 13:00Z == 2026-05-25 23:00 VLA → ещё 25-е + expect(toLocalDateKey("2026-05-25T13:00:00Z")).toBe("2026-05-25"); + // 2026-05-25 14:00Z == 2026-05-26 00:00 VLA → уже 26-е (граница суток) + expect(toLocalDateKey("2026-05-25T14:00:00Z")).toBe("2026-05-26"); + }); +}); + +describe("formatDayLabel", () => { + const now = new Date("2026-05-26T05:00:00Z"); // 15:00 VLA, день 26-го + it("Сегодня / Вчера относительно VLA-now", () => { + expect(formatDayLabel("2026-05-26", now)).toBe("Сегодня"); + expect(formatDayLabel("2026-05-25", now)).toBe("Вчера"); + }); + it("прочие даты — «D месяц»", () => { + expect(formatDayLabel("2026-05-20", now)).toBe("20 мая"); + }); +}); + +describe("groupByLocalDay", () => { + it("группирует по VLA-дню, суммирует works_total, сохраняет порядок", () => { + const items = [ + item(3, "2026-05-26T01:00:00Z", 100), // 26-е VLA + item(2, "2026-05-25T20:00:00Z", 50), // тоже 26-е VLA (06:00) + item(1, "2026-05-25T01:00:00Z", 200), // 25-е VLA + ]; + const groups = groupByLocalDay(items); + expect(groups.map((g) => g.key)).toEqual(["2026-05-26", "2026-05-25"]); + expect(groups[0].total).toBe(150); + expect(groups[0].items).toHaveLength(2); + expect(groups[1].total).toBe(200); + }); + + it("пустой вход → пустой массив", () => { + expect(groupByLocalDay([])).toEqual([]); + }); +}); + +describe("fmtMoney", () => { + it("округляет и форматирует тысячи (NBSP-разделитель)", () => { + expect(fmtMoney(91000).replace(/ /g, " ")).toBe("91 000"); + expect(fmtMoney(1499.6).replace(/ /g, " ")).toBe("1 500"); + }); +}); + +describe("склонения", () => { + it.each([ + [1, "ремонт"], + [2, "ремонта"], + [4, "ремонта"], + [5, "ремонтов"], + [11, "ремонтов"], + [21, "ремонт"], + [22, "ремонта"], + ])("pluralRepairs(%i) = %s", (n, exp) => { + expect(pluralRepairs(n)).toBe(exp); + }); + + it.each([ + [1, "работа"], + [3, "работы"], + [5, "работ"], + [12, "работ"], + ])("pluralWorks(%i) = %s", (n, exp) => { + expect(pluralWorks(n)).toBe(exp); + }); +}); diff --git a/mechanic-pwa/frontend/src/lib/repairsFeed.ts b/mechanic-pwa/frontend/src/lib/repairsFeed.ts new file mode 100644 index 0000000..a273272 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/repairsFeed.ts @@ -0,0 +1,81 @@ +/** + * Чистые помощники ленты ремонтов: группировка по локальной дате (VLA), + * форматирование сумм и склонения. Вынесены из RepairsFeedPage, чтобы быть + * переиспользуемыми и покрытыми юнит-тестами (TZ-логика группировки — + * частый источник off-by-one багов). + */ +import type { FeedItem } from "@/api/repairsFeed"; + +export interface DayGroup { + key: string; // YYYY-MM-DD (VLAT) + items: FeedItem[]; + total: number; +} + +/** ISO → дата в Asia/Vladivostok (+10) как YYYY-MM-DD. */ +export function toLocalDateKey(iso: string): string { + const d = new Date(iso); + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Vladivostok", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return fmt.format(d); // 2026-05-26 +} + +/** key = YYYY-MM-DD → «26 мая» / «Сегодня» / «Вчера» (относительно VLA-сегодня). */ +export function formatDayLabel(key: string, now: Date = new Date()): string { + const today = toLocalDateKey(now.toISOString()); + if (key === today) return "Сегодня"; + const yest = new Date(now.getTime() - 24 * 3600 * 1000); + if (key === toLocalDateKey(yest.toISOString())) return "Вчера"; + const [y, m, d] = key.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + return new Intl.DateTimeFormat("ru-RU", { + day: "numeric", + month: "long", + timeZone: "UTC", + }).format(dt); +} + +/** Группирует ленту по локальной дате; items уже отсортированы created_at DESC, + * поэтому ключи идут от свежих к старым. total — сумма works_total по дню. */ +export function groupByLocalDay(items: FeedItem[]): DayGroup[] { + const map = new Map(); + for (const it of items) { + const key = toLocalDateKey(it.created_at); + let g = map.get(key); + if (!g) { + g = { key, items: [], total: 0 }; + map.set(key, g); + } + g.items.push(it); + g.total += it.works_total || 0; + } + return Array.from(map.values()); +} + +export function fmtMoney(n: number): string { + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n)); +} + +/** 1 → «ремонт», 2..4 → «ремонта», 5+/11..14 → «ремонтов». */ +export function pluralRepairs(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 14) return "ремонтов"; + if (mod10 === 1) return "ремонт"; + if (mod10 >= 2 && mod10 <= 4) return "ремонта"; + return "ремонтов"; +} + +/** 1 → «работа», 2..4 → «работы», 5+/11..14 → «работ». */ +export function pluralWorks(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 14) return "работ"; + if (mod10 === 1) return "работа"; + if (mod10 >= 2 && mod10 <= 4) return "работы"; + return "работ"; +} diff --git a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx index c6272d1..23d0ccb 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx @@ -25,26 +25,8 @@ import { loadZoneLabels } from "@/data/zoneLabels"; import { damageTypeRuToEnum } from "@/data/damageVocabulary"; import { ttImageUrl } from "@/api/vehicles"; -const SLOTS_GENERAL: { side: string; label: string }[] = [ - { side: "front", label: "Перед" }, - { side: "rear", label: "Зад" }, - { side: "left", label: "Левый бок" }, - { side: "right", label: "Правый бок" }, - { side: "vin", label: "VIN" }, - { side: "odometer", label: "Одометр" }, - { side: "interior", label: "Салон" }, - { side: "free", label: "Свободное" }, -]; - -const SLOT_JACK = { side: "jack", label: "Домкрат" }; - -/** Все типы осмотров (выдача/приёмка/плановый/...) показывают одинаковый - * набор слотов общих ракурсов + домкрат. Раньше «return» исключал общие - * фото в пользу скорости приёмки, но фидбек: при приёмке всё равно надо - * фиксировать общее состояние машины, как и при выдаче/осмотре. */ -function slotsForType(_type: string): typeof SLOTS_GENERAL { - return [...SLOTS_GENERAL, SLOT_JACK]; -} +// Слоты фото осмотра — в @/lib/inspectionSlots (покрыто тестами). +import { slotsForType } from "@/lib/inspectionSlots"; const SEVERITY_LABELS: Record = { cosmetic: "косметика", diff --git a/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.test.tsx b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.test.tsx new file mode 100644 index 0000000..b90cb8a --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; + +const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() })); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigate }; +}); +vi.mock("@/api/repairsCreate", () => ({ + searchCars: vi.fn().mockResolvedValue([]), +})); + +import CarSelectPage from "@/pages/repairs/CarSelectPage"; + +beforeEach(() => navigate.mockClear()); + +test("«Машина не из парка» → ввод номера → navigate в external-режим со state (номер trim'ится)", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("+ Машина не из парка")); + await user.type(screen.getByPlaceholderText("Х999ХХ199"), " х999хх199 "); + await user.type(screen.getByPlaceholderText("Toyota Camry"), "Toyota Camry"); + await user.click(screen.getByText("Продолжить →")); + + expect(navigate).toHaveBeenCalledWith("/repairs/new-external", { + state: { plate: "х999хх199", make: "Toyota Camry" }, + }); +}); + +test("марка опциональна → make=null если поле пустое", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("+ Машина не из парка")); + await user.type(screen.getByPlaceholderText("Х999ХХ199"), "А001АА125"); + await user.click(screen.getByText("Продолжить →")); + + expect(navigate).toHaveBeenCalledWith("/repairs/new-external", { + state: { plate: "А001АА125", make: null }, + }); +}); + +test("«Продолжить» заблокирована без номера", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("+ Машина не из парка")); + expect(screen.getByText("Продолжить →").closest("button")).toBeDisabled(); +}); diff --git a/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx index 17b3e96..c369415 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx @@ -18,6 +18,15 @@ export default function CarSelectPage() { const [cars, setCars] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [extOpen, setExtOpen] = useState(false); + const [extPlate, setExtPlate] = useState(""); + const [extMake, setExtMake] = useState(""); + + const continueExternal = () => { + const plate = extPlate.trim(); + if (!plate) return; + navigate("/repairs/new-external", { state: { plate, make: extMake.trim() || null } }); + }; useEffect(() => { let cancelled = false; @@ -62,6 +71,13 @@ export default function CarSelectPage() {
+ {error && (
{error} @@ -96,6 +112,41 @@ export default function CarSelectPage() { )}
+ {extOpen && ( +
+
+ +

Машина с улицы

+
+
+
+ + setExtPlate(e.target.value)} + placeholder="Х999ХХ199" + className="mt-1" + onKeyDown={(e) => e.key === "Enter" && continueExternal()} + /> +
+
+ + setExtMake(e.target.value)} + placeholder="Toyota Camry" + className="mt-1" + /> +
+ +
+
+ )} ); } diff --git a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx index 735571f..5e3f374 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx @@ -15,18 +15,22 @@ * Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state). */ import { useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useLocation } from "react-router-dom"; import { ChevronLeft, Plus, Trash2, Pencil, Camera } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm"; +import WorksPickerModal from "@/components/repairs/WorksPickerModal"; +import PartsPickerModal from "@/components/repairs/PartsPickerModal"; +import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; +import { getMe } from "@/api/me"; import { createRepair, getCarContext, presignPhoto, searchWorks, - searchParts, uploadPhotoToTmp, type CarContext, type CreatePhotoPayload, @@ -42,6 +46,10 @@ interface PhotoState { tag: "auto" | "part" | "other"; status: "uploading" | "uploaded" | "error"; errorMessage?: string; + // Превью локального файла через URL.createObjectURL — отдаётся в + // вместо плейсхолдера «Загружено». Освобождается через revokeObjectURL + // при удалении и при размонтировании страницы. + previewUrl?: string; } interface WorkRow { @@ -51,7 +59,10 @@ interface WorkRow { work_catalog_id: number; name: string; // снапшот имени из прайса price_catalog: number; - price_applied: number; + // "" допустим в процессе редактирования — иначе React при очистке inputа + // сразу подставляет "0" и его приходится курсором обходить, см. жалобу + // механиков. На submit пустое трактуется как 0 (или как ошибка валидации). + price_applied: number | ""; unlocked: boolean; // карандашик разблокировал поле override_comment: string; } @@ -67,15 +78,23 @@ interface PartRow { interface OilChangeState { enabled: boolean; - mileage_at_change: number | null; // общий одометр — один на все замены заезда - mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным next_in_km: number; // интервал двигателя - // Какие масла меняли за заезд (можно оба сразу): + // Какие масла меняли за заезд (можно оба сразу). types: { engine: boolean; transmission: boolean }; + // Пробега здесь нет намеренно: он один на весь ремонт (У24), берётся из + // шапки формы — механик не должен вводить одно и то же дважды. sticker: PhotoState | null; } +// Цены ремонта — целые рубли без копеек. API отдаёт Numeric(10,2) +// строкой типа "500.00" — пропускаем через округление при отображении. +function fmtPrice(p: number | string): string { + const n = Number(p); + if (Number.isNaN(n)) return String(p); + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n)); +} + function uuid4(): string { // crypto.randomUUID требует secure context; mechanic.pptaxi.ru — https. return (crypto as any).randomUUID @@ -87,6 +106,9 @@ function uuid4(): string { export default function CreateRepairPage() { const navigate = useNavigate(); const { carId: carIdRaw } = useParams<{ carId: string }>(); + const location = useLocation(); + const isExternal = !carIdRaw; + const externalState = location.state as { plate: string; make: string | null } | null; const carId = carIdRaw ? Number(carIdRaw) : 0; // Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20). @@ -99,10 +121,11 @@ export default function CreateRepairPage() { const [photos, setPhotos] = useState([]); const [works, setWorks] = useState([]); const [parts, setParts] = useState([]); + // Работавшие механики (делёж 1/N). По умолчанию — текущий механик + // (создатель), подтягивается из /me. Можно убрать/заменить. + const [mechanics, setMechanics] = useState([]); const [oilChange, setOilChange] = useState({ enabled: false, - mileage_at_change: null, - mileage_is_dirty: false, next_in_km: 10000, types: { engine: true, transmission: false }, sticker: null, @@ -116,6 +139,24 @@ export default function CreateRepairPage() { // Initial: подгрузить контекст машины. useEffect(() => { + if (isExternal) { + if (!externalState) { + // Прямой заход/рефреш без данных — возвращаем к выбору машины. + navigate("/repairs/new", { replace: true }); + return; + } + setCtx({ + id: 0, + plate: externalState.plate, + brand: externalState.make, + model: null, + mileage_starline: null, + driver_id: null, + driver_name: null, + }); + setMileage(null); + return; + } if (!carId) return; getCarContext(carId) .then((c) => { @@ -123,15 +164,22 @@ export default function CreateRepairPage() { setMileage(c.mileage_starline); }) .catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину")); - }, [carId]); - - // У24: мягкая подписка пробега блока масла на шапочный. - useEffect(() => { - if (oilChange.enabled && !oilChange.mileage_is_dirty) { - setOilChange((oc) => ({ ...oc, mileage_at_change: mileage })); - } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mileage, oilChange.enabled]); + }, [carId, isExternal]); + + // Дефолтный механик-создатель: подтягиваем себя из /me. Если /me упал — + // оставляем список пустым (backend всё равно засидит создателя на create). + useEffect(() => { + getMe() + .then((me) => setMechanics([{ mechanic_id: me.id, name: me.name }])) + .catch(() => { + /* noop — список останется пустым, backend засидит создателя */ + }); + }, []); + + // У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)» + // в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы + // не заставлять механика вводить одно и то же дважды. // ── Photo upload ───────────────────────────────────────────────────────── @@ -139,6 +187,7 @@ export default function CreateRepairPage() { if (!files || files.length === 0) return; for (const file of Array.from(files)) { const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); + const previewUrl = URL.createObjectURL(file); const placeholder: PhotoState = { presign: { photo_uuid: uuid4(), @@ -150,6 +199,7 @@ export default function CreateRepairPage() { }, tag: "auto", status: "uploading", + previewUrl, }; if (target === "gallery") setPhotos((arr) => [...arr, placeholder]); else setOilChange((oc) => ({ ...oc, sticker: { ...placeholder, tag: "auto" } })); @@ -157,7 +207,7 @@ export default function CreateRepairPage() { try { const presign = await presignPhoto(ext, placeholder.presign.photo_uuid); await uploadPhotoToTmp(file, presign); - const updated: PhotoState = { presign, tag: "auto", status: "uploaded" }; + const updated: PhotoState = { presign, tag: "auto", status: "uploaded", previewUrl }; if (target === "gallery") { setPhotos((arr) => arr.map((p) => (p.presign.photo_uuid === presign.photo_uuid ? updated : p)), @@ -186,7 +236,11 @@ export default function CreateRepairPage() { }; const removePhoto = (photoUuid: string) => - setPhotos((arr) => arr.filter((p) => p.presign.photo_uuid !== photoUuid)); + setPhotos((arr) => { + const target = arr.find((p) => p.presign.photo_uuid === photoUuid); + if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl); + return arr.filter((p) => p.presign.photo_uuid !== photoUuid); + }); // ── Works picker ───────────────────────────────────────────────────────── @@ -203,7 +257,7 @@ export default function CreateRepairPage() { }; const addWorkFromCatalog = (w: MechWork) => { - const price = Number(w.price); + const price = Math.round(Number(w.price)); setWorks((arr) => [ ...arr, { @@ -242,35 +296,7 @@ export default function CreateRepairPage() { // ── Parts ──────────────────────────────────────────────────────────────── - const [partsPicker, setPartsPicker] = useState<{ - open: boolean; q: string; showAll: boolean; results: PartCatalogItem[]; loading: boolean; - }>({ open: false, q: "", showAll: false, results: [], loading: false }); - - const loadParts = async (q: string, showAll: boolean) => { - setPartsPicker((s) => ({ ...s, loading: true })); - try { - const list = await searchParts(q, carId, showAll); - setPartsPicker((s) => ({ ...s, results: list, loading: false })); - } catch { - setPartsPicker((s) => ({ ...s, results: [], loading: false })); - } - }; - - const openPartsPicker = () => { - setPartsPicker({ open: true, q: "", showAll: false, results: [], loading: true }); - void loadParts("", false); - }; - - const queryParts = (q: string) => { - setPartsPicker((s) => ({ ...s, q })); - void loadParts(q, partsPicker.showAll); - }; - - const togglePartsShowAll = () => { - const next = !partsPicker.showAll; - setPartsPicker((s) => ({ ...s, showAll: next })); - void loadParts(partsPicker.q, next); - }; + const [partsPickerOpen, setPartsPickerOpen] = useState(false); const addPartFromCatalog = (item: PartCatalogItem) => { setParts((arr) => { @@ -284,7 +310,7 @@ export default function CreateRepairPage() { { rowKey: uuid4(), part_id: item.id, name: item.name, article: item.article, in_stock: item.in_stock, qty: 1 }, ]; }); - setPartsPicker({ open: false, q: "", showAll: false, results: [], loading: false }); + setPartsPickerOpen(false); }; const removePart = (rowKey: string) => @@ -292,22 +318,17 @@ export default function CreateRepairPage() { // ── Submit ─────────────────────────────────────────────────────────────── - const canSubmit = () => - photos.length > 0 && - photos.every((p) => p.status === "uploaded") && - works.length > 0 && - works.every((w) => !w.unlocked || w.override_comment.trim().length > 0); - + // Общая часть гейтинга живёт в lib/repairForm (покрыта тестами, шарится с + // формой правки); здесь добавляется только специфичная для создания проверка + // выбранного типа масла. const submitWhyDisabled = (): string => { - if (photos.length === 0) return "Добавьте хотя бы одно фото"; - if (photos.some((p) => p.status !== "uploaded")) return "Дождитесь окончания загрузки фото"; - if (works.length === 0) return "Добавьте хотя бы одну работу"; - const unlockedNoComment = works.find((w) => w.unlocked && !w.override_comment.trim()); - if (unlockedNoComment) return "Укажите комментарий к ручной правке цены"; + const base = repairSubmitWhyDisabled(photos, works); + if (base) return base; if (oilChange.enabled && !oilChange.types.engine && !oilChange.types.transmission) return "Выберите тип масла (двигатель и/или коробка) или снимите «Была замена масла»"; return ""; }; + const canSubmit = () => repairCanSubmit(photos, works) && submitWhyDisabled() === ""; const onSubmit = async () => { const reason = submitWhyDisabled(); @@ -325,7 +346,14 @@ export default function CreateRepairPage() { })); const result = await createRepair( { - car_id: carId, + ...(isExternal + ? { external_car: { plate: externalState!.plate, make: externalState!.make } } + : { car_id: carId }), + // Передаём список работавших механиков; если пуст — backend засидит + // создателя сам (см. seed_creator_mechanic). + ...(mechanics.length > 0 + ? { mechanic_ids: mechanics.map((m) => m.mechanic_id) } + : {}), mileage, works: works.map((w) => ({ work_catalog_id: w.work_catalog_id, @@ -340,7 +368,8 @@ export default function CreateRepairPage() { .filter((t) => oilChange.types[t]) .map((t, idx) => ({ enabled: true, - mileage_at_change: oilChange.mileage_at_change, + // Один источник истины — главный пробег ремонта (см. У24). + mileage_at_change: mileage, next_in_km: t === "engine" ? oilChange.next_in_km : 0, oil_type: t, // Стикер прикрепляем к первой выбранной замене (обычно двигатель). @@ -406,7 +435,11 @@ export default function CreateRepairPage() {
{ctx.plate}
{[ctx.brand, ctx.model].filter(Boolean).join(" ")} - {ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`} + {isExternal + ? " · С улицы" + : ctx.driver_name + ? ` · ${ctx.driver_name}` + : " · Без водителя"}
@@ -437,17 +470,28 @@ export default function CreateRepairPage() {
{photos.map((p) => (
+ {p.previewUrl && ( + + )} {p.status === "uploading" && ( -
Загрузка…
+
+ Загрузка… +
)} {p.status === "error" && ( -
+
{p.errorMessage || "Ошибка"}
)} {p.status === "uploaded" && ( -
- ✓ Загружено +
+ + +
)} @@ -645,21 +706,6 @@ export default function CreateRepairPage() {

Выберите хотя бы один тип масла.

)}
-
- - - setOilChange((oc) => ({ - ...oc, - mileage_at_change: e.target.value === "" ? null : Number(e.target.value), - mileage_is_dirty: true, - })) - } - /> -
{oilChange.types.engine && (
@@ -680,17 +726,34 @@ export default function CreateRepairPage() { )}
-
); } diff --git a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx new file mode 100644 index 0000000..8207444 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx @@ -0,0 +1,614 @@ +/** + * Правка ремонта в 1-часовом окне (У49). + * + * Минимальная версия: редактируются работы (имя из каталога + цена + + * ручная правка + комментарий), запчасти, замена масла, пробег. Фото + * НЕ редактируются — для смены фото нужно дождаться PR-9. + */ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ChevronLeft, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + getRepairDetail, + patchRepair, + type PatchOilChange, + type RepairDetailResponse, +} from "@/api/repairsFeed"; +import { searchWorks, type MechWork, type PartCatalogItem } from "@/api/repairsCreate"; +import WorksPickerModal from "@/components/repairs/WorksPickerModal"; +import PartsPickerModal from "@/components/repairs/PartsPickerModal"; +import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; + +interface WorkRow { + rowKey: string; + work_catalog_id: number; + name: string; + price_catalog: number; + price_applied: number; + manual_override: boolean; + override_comment: string | null; +} + +interface PartRow { + rowKey: string; + // null — историческая строка свободным текстом (до перевода на каталог). + // Такие строки сохраняются как есть и склад не трогают. + part_id: number | null; + name: string; + article: string | null; + in_stock: number | null; + qty: number; +} + +interface OilState { + enabled: boolean; + // За заезд бывает несколько замен (двигатель + коробка) — храним набор + // типов, как в форме создания, иначе правка молча схлопнула бы вторую. + types: { engine: boolean; transmission: boolean }; + next_in_km: number | null; // интервал двигателя +} + +// Типы масла коробки бэкенд хранит как cvt/akpp; форме хватает одного +// переключателя «Коробка» — конкретный тип берётся из уже сохранённого. +function isGearboxOil(t: string): boolean { + return t === "transmission" || t === "cvt" || t === "akpp"; +} + +function uid(): string { + return Math.random().toString(36).slice(2, 10); +} + +function fmt(n: number): string { + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n)); +} + +export default function EditRepairPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Form state. + const [mileage, setMileage] = useState(null); + const [works, setWorks] = useState([]); + const [parts, setParts] = useState([]); + const [oil, setOil] = useState({ + enabled: false, types: { engine: true, transmission: false }, next_in_km: null, + }); + // Тип масла коробки как он сохранён (cvt/akpp) — чтобы правка его не меняла. + const [gearboxOilType, setGearboxOilType] = useState("transmission"); + // Работавшие механики (делёж 1/N). + const [mechanics, setMechanics] = useState([]); + + const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>( + { open: false, q: "", results: [] } + ); + const [overrideModal, setOverrideModal] = useState<{ rowKey: string; comment: string } | null>(null); + const [partsPickerOpen, setPartsPickerOpen] = useState(false); + + const openPartsPicker = () => setPartsPickerOpen(true); + + const addPartFromCatalog = (item: PartCatalogItem) => { + setParts((arr) => { + // Та же деталь уже в списке — увеличиваем количество, а не плодим строку. + const existing = arr.find((p) => p.part_id === item.id); + if (existing) return arr.map((p) => (p.part_id === item.id ? { ...p, qty: p.qty + 1 } : p)); + return [...arr, { + rowKey: uid(), part_id: item.id, name: item.name, + article: item.article, in_stock: item.in_stock, qty: 1, + }]; + }); + setPartsPickerOpen(false); + }; + + useEffect(() => { + if (!id) return; + setLoading(true); + getRepairDetail(Number(id)) + .then((d) => { + setDetail(d); + setMileage(d.mileage); + setWorks(d.works.map((w) => ({ + rowKey: uid(), + work_catalog_id: w.work_catalog_id, + name: w.name, + price_catalog: w.price_applied, // в payload мы не знаем catalog price отдельно — фронт хранит applied; PATCH перечитает из БД каталога + price_applied: w.price_applied, + manual_override: w.manual_override, + override_comment: w.override_comment, + }))); + setParts(d.parts.map((p) => ({ + rowKey: uid(), part_id: p.part_id, name: p.name, + article: p.article, in_stock: null, qty: p.qty, + }))); + const oils = d.oil_changes?.length ? d.oil_changes : d.oil_change ? [d.oil_change] : []; + if (oils.length > 0) { + const engine = oils.find((o) => !isGearboxOil(o.oil_type)); + const gearbox = oils.find((o) => isGearboxOil(o.oil_type)); + if (gearbox) setGearboxOilType(gearbox.oil_type); + setOil({ + enabled: true, + types: { engine: !!engine, transmission: !!gearbox }, + next_in_km: engine?.next_in_km ?? null, + }); + } + // Механики: из списка работавших; fallback — создатель (старые ответы). + if (d.mechanics && d.mechanics.length > 0) { + setMechanics(d.mechanics.map((m) => ({ mechanic_id: m.mechanic_id, name: m.name }))); + } else if (d.mechanic_name) { + // Нет id создателя в payload — оставляем имя без id-сопоставления; + // если механик ничего не трогает, mechanic_ids не отправим. + setMechanics([]); + } + if (!d.can_edit) { + setError("Окно правки закрыто. Изменения отправить нельзя."); + } + }) + .catch((e: Error) => setError(e.message || "Не удалось загрузить ремонт")) + .finally(() => setLoading(false)); + }, [id]); + + const totalSum = useMemo(() => works.reduce((s, w) => s + (w.price_applied || 0), 0), [works]); + + // Works picker — поиск по каталогу. + const openWorksPicker = async () => { + setWorksPicker({ open: true, q: "", results: [] }); + const list = await searchWorks(""); + setWorksPicker((s) => ({ ...s, results: list })); + }; + const queryWorks = async (q: string) => { + setWorksPicker((s) => ({ ...s, q })); + const list = await searchWorks(q); + setWorksPicker((s) => ({ ...s, results: list })); + }; + const pickWork = (mw: MechWork) => { + const p = Math.round(Number(mw.price)); + setWorks((arr) => [...arr, { + rowKey: uid(), + work_catalog_id: mw.id, + name: mw.name, + price_catalog: p, + price_applied: p, + manual_override: false, + override_comment: null, + }]); + setWorksPicker({ open: false, q: "", results: [] }); + }; + + // Override-цена: открываем модалку для комментария. + const startOverride = (rowKey: string) => { + const cur = works.find((w) => w.rowKey === rowKey); + setOverrideModal({ rowKey, comment: cur?.override_comment ?? "" }); + }; + const confirmOverride = (newPrice: number) => { + if (!overrideModal) return; + setWorks((arr) => arr.map((w) => + w.rowKey === overrideModal.rowKey + ? { ...w, price_applied: newPrice, manual_override: true, override_comment: overrideModal.comment } + : w + )); + setOverrideModal(null); + }; + + const submit = async () => { + if (!detail) return; + setError(null); + + // Валидация: каждый manual_override → комментарий. + for (const w of works) { + if (w.manual_override && !(w.override_comment || "").trim()) { + setError(`У работы «${w.name}» включена ручная правка цены, но не указан комментарий`); + return; + } + } + if (works.length === 0) { + setError("Минимум одна работа в ремонте"); + return; + } + // Механики были загружены, но пользователь убрал всех — backend отклонит + // пустой список, перехватываем заранее с понятным текстом. + if ((detail.mechanics?.length ?? 0) > 0 && mechanics.length === 0) { + setError("Минимум один механик в ремонте"); + return; + } + if (oil.enabled && !oil.types.engine && !oil.types.transmission) { + setError("Выберите тип масла (двигатель и/или коробка) или снимите «Замена масла»"); + return; + } + + setSaving(true); + try { + await patchRepair(detail.id, { + mileage, + works: works.map((w) => ({ + work_catalog_id: w.work_catalog_id, + price_applied: Math.round(w.price_applied), + manual_override: w.manual_override, + override_comment: w.manual_override ? (w.override_comment ?? "").trim() : null, + })), + // part_id обязателен для новых строк — по нему бэкенд сверяет списание + // со склада. Исторические строки без part_id уходят как были. + parts: parts.filter((p) => p.name.trim().length > 0).map((p) => ({ + part_id: p.part_id, + name: p.name.trim(), + qty: p.qty, + })), + // Полная замена набора: пустой список = замен не было. Пробег — + // главный пробег ремонта (один источник истины). + oil_changes: oil.enabled + ? ([ + oil.types.engine + ? { enabled: true, mileage_at_change: mileage, next_in_km: oil.next_in_km, oil_type: "engine" } + : null, + oil.types.transmission + ? { enabled: true, mileage_at_change: mileage, next_in_km: 0, oil_type: gearboxOilType } + : null, + ].filter(Boolean) as PatchOilChange[]) + : [], + // Список механиков отправляем, только если он непуст (иначе бэк + // оставит набор как есть; пустой бы вызвал ValueError). + ...(mechanics.length > 0 + ? { mechanic_ids: mechanics.map((m) => m.mechanic_id) } + : {}), + }); + navigate(`/repairs/${detail.id}`, { replace: true }); + } catch (e) { + setError((e as Error).message || "Не удалось сохранить"); + setSaving(false); + } + }; + + return ( +
+
+
+ +

+ Правка ремонта {detail ? `#${detail.id}` : ""} +

+
+
+ +
+ {loading && ( +
+ Загрузка… +
+ )} + {error && ( +
+ {error} +
+ )} + {detail && ( + <> + {/* Шапка машины — read-only */} +
+
{detail.car_plate ?? "—"}
+ {(detail.car_make || detail.car_model) && ( +
{detail.car_make} {detail.car_model}
+ )} + {detail.driver_name && ( +
+ Водитель: + {detail.driver_name} +
+ )} +
+ + {/* Пробег */} +
+ + setMileage(e.target.value === "" ? null : Number(e.target.value))} + className="mt-1" + /> +
+ + {/* Работы */} +
+
+ + + {works.length} {works.length === 1 ? "шт" : "шт"} + +
+
+ {works.map((w) => ( +
+
+
+
{w.name}
+
+ + {fmt(w.price_applied)} ₽ + + {w.manual_override && ( + + ✎ ручная правка + + )} + {w.manual_override && w.price_applied !== w.price_catalog && ( + + {fmt(w.price_catalog)} + + )} +
+ {w.manual_override && w.override_comment && ( +
+ {w.override_comment} +
+ )} +
+ + +
+
+ ))} + +
+ Итого + {fmt(totalSum)} ₽ +
+
+
+ + {/* Механики (делёж 1/N) */} + + + {/* Запчасти — из складского каталога (списываются со склада) */} +
+
+ + {parts.length} шт +
+
+ {parts.map((p) => ( +
+
+
{p.name}
+
+ {p.article ? `арт. ${p.article}` : p.part_id == null ? "вне каталога" : ""} + {p.in_stock != null ? ` · на складе: ${p.in_stock}` : ""} +
+
+ + setParts((arr) => arr.map((x) => + x.rowKey === p.rowKey ? { ...x, qty: Math.max(1, Number(e.target.value)) } : x)) + } + className="w-20" + /> + +
+ ))} + +
+
+ + {/* Замена масла */} +
+ + {oil.enabled && ( +
+
+ +
+ {( + [ + ["engine", "Двигатель"], + ["transmission", "Коробка"], + ] as ["engine" | "transmission", string][] + ).map(([v, l]) => ( + + ))} +
+ {!oil.types.engine && !oil.types.transmission && ( +

Выберите хотя бы один тип масла.

+ )} +
+ {oil.types.engine && ( +
+ + setOil((o) => ({ + ...o, next_in_km: e.target.value === "" ? null : Number(e.target.value), + }))} + /> +
+ )} + {oil.types.transmission && ( +

+ Интервал коробки задаётся по модели авто в отчёте СТО. +

+ )} +
+ )} +
+ +
+ Фото и стикер замены масла редактировать пока нельзя — для смены + нужно создать новый ремонт. +
+ + )} +
+ + {/* Sticky footer */} + {detail && ( +
+ + +
+ )} + + {/* Works picker modal — общий компонент (см. WorksPickerModal) */} + {worksPicker.open && ( + setWorksPicker({ open: false, q: "", results: [] })} + /> + )} + + {/* Parts picker modal — каталог склада (общий с формой создания) */} + setPartsPickerOpen(false)} + /> + + {/* Override modal */} + {overrideModal && (() => { + const cur = works.find((w) => w.rowKey === overrideModal.rowKey); + if (!cur) return null; + return ( + setOverrideModal(null)} + onSubmit={(newPrice, comment) => { + setOverrideModal((m) => m ? { ...m, comment } : m); + confirmOverride(newPrice); + }} + /> + ); + })()} +
+ ); +} + + +function OverridePriceModal({ workName, catalogPrice, appliedPrice, initialComment, onCancel, onSubmit }: { + workName: string; + catalogPrice: number; + appliedPrice: number; + initialComment: string; + onCancel: () => void; + onSubmit: (newPrice: number, comment: string) => void; +}) { + const [price, setPrice] = useState(String(Math.round(appliedPrice))); + const [comment, setComment] = useState(initialComment); + + return ( +
+
e.stopPropagation()}> +

Изменить цену

+
+
{workName}
+
+ По каталогу: {fmt(catalogPrice)} ₽ +
+
+
+ + setPrice(e.target.value)} + autoFocus + /> +
+
+ +