From ad59697ac551a00a8a0b8fc55b6e557642fe5acc Mon Sep 17 00:00:00 2001 From: Kairgeldin Dmitry Date: Tue, 3 Feb 2026 14:34:43 +0500 Subject: [PATCH 01/63] feat: pwa --- .gitignore | 10 ++ README.md | 8 ++ app/layout.tsx | 21 ++++ app/manifest.ts | 39 +++++++ app/sw.ts | 35 +++++++ app/~offline/page.tsx | 14 +++ bun.lock | 134 ++++++++++++++++++++++++- messages/kz.json | 7 ++ messages/ru.json | 7 ++ next.config.ts | 39 ++++++- package.json | 3 + public/icon-192x192.png | Bin 0 -> 4019 bytes public/icon-512x512.png | Bin 0 -> 10866 bytes src/widgets/navigation/app-sidebar.tsx | 2 + src/widgets/ui/index.ts | 1 + src/widgets/ui/pwa-install-prompt.tsx | 131 ++++++++++++++++++++++++ tsconfig.json | 5 +- 17 files changed, 446 insertions(+), 10 deletions(-) create mode 100644 app/manifest.ts create mode 100644 app/sw.ts create mode 100644 app/~offline/page.tsx create mode 100644 public/icon-192x192.png create mode 100644 public/icon-512x512.png create mode 100644 src/widgets/ui/pwa-install-prompt.tsx diff --git a/.gitignore b/.gitignore index 5ef6a52..64946a6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,16 @@ yarn-error.log* # vercel .vercel +# Serwist (PWA) +/public/sw.js +/public/sw.js.map +/public/swe-worker-*.js +/public/swe-worker-*.js.map +/public/workbox-*.js +/public/workbox-*.js.map +/public/serialized-*.js +/public/serialized-*.js.map + # typescript *.tsbuildinfo next-env.d.ts diff --git a/README.md b/README.md index e215bc4..ee00cfe 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,11 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## PWA + +Приложение настроено как PWA (manifest, Serwist, офлайн-страница, иконки). + +- **Проверка:** Chrome DevTools → Application → Manifest, Service Workers. +- **Lighthouse:** запустить аудит PWA и Performance по HTTPS (`next build` + `next start` или деплой). +- **Локально с HTTPS:** `next dev --experimental-https` для тестов установки и push (если позже добавите). diff --git a/app/layout.tsx b/app/layout.tsx index d4deee4..e9f0dea 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,6 +19,7 @@ export default function RootLayout({ return ( + {/* Favicon */} + {/* PWA: theme-color для standalone */} + + + {/* Apple: установка на главный экран */} + + + + diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..b713287 --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,39 @@ +import type { MetadataRoute } from "next"; + +// Константы PWA (manifest обычно на одном языке) +const PWA_NAME = "Bagsy — управление записями"; +const PWA_SHORT_NAME = "Bagsy"; +const PWA_DESCRIPTION = "Управление записями и календарь"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: PWA_NAME, + short_name: PWA_SHORT_NAME, + description: PWA_DESCRIPTION, + start_url: "/", + scope: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#000000", + icons: [ + { + src: "/icon-192x192.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/app/sw.ts b/app/sw.ts new file mode 100644 index 0000000..3f0b5be --- /dev/null +++ b/app/sw.ts @@ -0,0 +1,35 @@ +/// + +import { defaultCache } from "@serwist/next/worker"; +import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"; +import { Serwist } from "serwist"; + +// Манифест precache подставляется при сборке +declare global { + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; + } +} + +declare const self: ServiceWorkerGlobalScope; + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, + // Офлайн: для document показываем страницу /~offline + fallbacks: { + entries: [ + { + url: "/~offline", + matcher({ request }) { + return request.destination === "document"; + }, + }, + ], + }, +}); + +serwist.addEventListeners(); diff --git a/app/~offline/page.tsx b/app/~offline/page.tsx new file mode 100644 index 0000000..33096ad --- /dev/null +++ b/app/~offline/page.tsx @@ -0,0 +1,14 @@ +/** + * Страница-заглушка при офлайн (PWA fallback). + * Показывается Serwist, когда запрос document не может быть выполнен из сети/кэша. + */ +export default function OfflinePage() { + return ( +
+

Нет подключения

+

+ Проверьте интернет и обновите страницу. +

+
+ ); +} diff --git a/bun.lock b/bun.lock index d7284bc..6c2177c 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@serwist/next": "^9.5.3", "@tabler/icons-react": "^3.34.1", "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.89.0", @@ -36,6 +37,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "leaflet": "^1.9.4", "lucide-react": "^0.544.0", "next": "^16.1.1", "next-intl": "^4.3.8", @@ -47,6 +49,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.3", "react-hook-form": "^7.63.0", + "react-leaflet": "^5.0.0", "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", @@ -57,6 +60,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -68,6 +72,8 @@ "husky": "^9.1.7", "postcss": "^8.5.6", "prettier": "^3.6.2", + "serwist": "^9.5.3", + "sharp": "^0.34.5", "tailwindcss": "^4.1.18", "typescript": "^5", }, @@ -200,6 +206,8 @@ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -240,6 +248,8 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -436,6 +446,8 @@ "@react-dnd/shallowequal": ["@react-dnd/shallowequal@4.0.2", "", {}, "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="], + "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="], + "@react-stately/autocomplete": ["@react-stately/autocomplete@3.0.0-beta.3", "", { "dependencies": { "@react-stately/utils": "^3.10.8", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-YfP/TrvkOCp6j7oqpZxJSvmSeXn+XtbKSOiBOuo+m2zCIhW2ncThmDB9uAUOkpmikDv/LkGKni40RQE8USdGdA=="], "@react-stately/calendar": ["@react-stately/calendar@3.8.4", "", { "dependencies": { "@internationalized/date": "^3.9.0", "@react-stately/utils": "^3.10.8", "@react-types/calendar": "^3.7.4", "@react-types/shared": "^3.32.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-q9mq0ydOLS5vJoHLnYfSCS/vppfjbg0XHJlAoPR+w+WpYZF4wPP453SrlX9T1DbxCEYFTpcxcMk/O8SDW3miAw=="], @@ -560,6 +572,16 @@ "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], + "@serwist/build": ["@serwist/build@9.5.3", "", { "dependencies": { "@serwist/utils": "9.5.3", "common-tags": "1.8.2", "glob": "10.5.0", "pretty-bytes": "6.1.1", "source-map": "0.8.0-beta.0", "zod": "4.3.6" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-P20GPPB4lFEQawA0WUdkWa5RNnWfQIqupNKk7eY8aYmio4P5hQQci5GmA7sQCNw+rxWKTkqzCoiw1F53+uCbog=="], + + "@serwist/next": ["@serwist/next@9.5.3", "", { "dependencies": { "@serwist/build": "9.5.3", "@serwist/utils": "9.5.3", "@serwist/webpack-plugin": "9.5.3", "@serwist/window": "9.5.3", "browserslist": "4.28.1", "glob": "10.5.0", "kolorist": "1.8.0", "semver": "7.7.3", "serwist": "9.5.3", "zod": "4.3.6" }, "peerDependencies": { "@serwist/cli": "^9.5.3", "next": ">=14.0.0", "react": ">=18.0.0", "typescript": ">=5.0.0" }, "optionalPeers": ["@serwist/cli", "typescript"] }, "sha512-ZUrzfIVMJw4RyA2Sm8ah0qw5utute9A/oI48SAnVRRZVben6FiRy8A27dHpf0Ll01Yc6/57yr4arO3R6OLOCRA=="], + + "@serwist/utils": ["@serwist/utils@9.5.3", "", { "peerDependencies": { "browserslist": ">=4" }, "optionalPeers": ["browserslist"] }, "sha512-0xIce0kTZdL+ErngUuKOvPoSpLetldnQBNFB84WYWudaMW1MqYdU3SdftLGCJdEGlLc0gOzQ2fjeT+U6bsa9Zg=="], + + "@serwist/webpack-plugin": ["@serwist/webpack-plugin@9.5.3", "", { "dependencies": { "@serwist/build": "9.5.3", "@serwist/utils": "9.5.3", "pretty-bytes": "6.1.1", "zod": "4.3.6" }, "peerDependencies": { "typescript": ">=5.0.0", "webpack": "4.4.0 || ^5.9.0" }, "optionalPeers": ["typescript", "webpack"] }, "sha512-0oZK2t3YEMFSKqqHH7rM6wdlpqujj//zfzoJmjHSI3H9fvgyi3O/PO14nddzzPcYZdTiprv+V8TSH5Ec5TSclw=="], + + "@serwist/window": ["@serwist/window@9.5.3", "", { "dependencies": { "@types/trusted-types": "2.0.7", "serwist": "9.5.3" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-QmBMO9JpsAZAnjWceIRo3zvZw19Gurqb84ExJ+hH0SuL2A+3l81Rp+jIvc8xUYA7sSE4PTAxcucFuQyoyqCMBw=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -632,16 +654,22 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + "@types/node": ["@types/node@20.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-gqiKWld3YIkmtrrg9zDvg9jfksZCcPywXVN7IauUGhilwGV/yOyeUsvpR796m/Jye0zUzMXPKe8Ct1B79A7N5Q=="], "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], "@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.43.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/type-utils": "8.43.0", "@typescript-eslint/utils": "8.43.0", "@typescript-eslint/visitor-keys": "8.43.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.43.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.43.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", "@typescript-eslint/typescript-estree": "8.43.0", "@typescript-eslint/visitor-keys": "8.43.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw=="], @@ -708,6 +736,8 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -750,6 +780,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -772,6 +804,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -836,6 +870,10 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.283", "", {}, "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w=="], + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], @@ -856,6 +894,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.35.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.35.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg=="], @@ -924,6 +964,8 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -940,6 +982,8 @@ "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -970,6 +1014,8 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1004,6 +1050,8 @@ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -1040,6 +1088,8 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jiti": ["jiti@1.21.7", "", { "bin": "bin/jiti.js" }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1058,10 +1108,14 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -1094,8 +1148,12 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1110,6 +1168,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -1126,6 +1186,8 @@ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1150,6 +1212,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -1158,6 +1222,8 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1172,6 +1238,8 @@ "prettier-linter-helpers": ["prettier-linter-helpers@1.0.0", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w=="], + "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1196,6 +1264,8 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], @@ -1236,7 +1306,9 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "serwist": ["serwist@9.5.3", "", { "dependencies": { "@serwist/utils": "9.5.3", "idb": "8.0.3" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-ZNYJkFjg5EuXUlX29AvgOhl+8mDMBJhP9xGfaP3rovEzSH5avs4L1mkHQtFm1IwwYwtJBhelyjFnEDkmNGGmtA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1258,14 +1330,22 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], @@ -1278,6 +1358,10 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -1304,6 +1388,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], @@ -1328,6 +1414,8 @@ "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1342,6 +1430,10 @@ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], + + "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1354,6 +1446,10 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], @@ -1364,6 +1460,12 @@ "@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.1", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg=="], + "@serwist/build/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@serwist/next/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@serwist/webpack-plugin/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -1384,7 +1486,7 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -1392,13 +1494,17 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "is-bun-module/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1406,10 +1512,28 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "sharp/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/messages/kz.json b/messages/kz.json index da0a753..70829b4 100644 --- a/messages/kz.json +++ b/messages/kz.json @@ -19,6 +19,13 @@ "logout": "Шығу" } }, + "PwaInstallPrompt": { + "add-to-home-screen": "Басты экранға қосу", + "share": "Бөлісу", + "to-home-screen": "Басты экранға", + "install-app": "Қолданбасын орнату", + "install": "Орнату" + }, "Dashboard": { "content": { "calendar": "Күнтізбе" }, "title": { diff --git a/messages/ru.json b/messages/ru.json index 1004aef..2aed92e 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -19,6 +19,13 @@ "logout": "Выйти" } }, + "PwaInstallPrompt": { + "add-to-home-screen": "Добавить на главный экран", + "share": "Поделиться", + "to-home-screen": "На экран \"Домой\"", + "install-app": "Установить приложение", + "install": "Установить" + }, "Dashboard": { "content": { "calendar": "Календарь" }, "title": { diff --git a/next.config.ts b/next.config.ts index 1f9ca5d..ed46b49 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,14 +1,47 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +import withSerwistInit from "@serwist/next"; -// Создаем плагин для next-intl +// Плагин next-intl const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); +// Ревизия для precache (при обновлении SW инвалидируется кэш) +const revision = crypto.randomUUID?.() ?? Date.now().toString(); + +// PWA: Serwist — офлайн-кэш и precache (только в production; Turbopack не поддерживается) +const withSerwist = withSerwistInit({ + swSrc: "app/sw.ts", + swDest: "public/sw.js", + additionalPrecacheEntries: [{ url: "/~offline", revision }], + register: true, + scope: "/", + disable: process.env.NODE_ENV !== "production", +}); + const nextConfig: NextConfig = { - // Настройка для правильного разрешения путей typescript: { ignoreBuildErrors: false, }, + // PWA: безопасные заголовки (глобально + для SW) + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + ], + }, + { + source: "/sw.js", + headers: [ + { key: "Content-Type", value: "application/javascript; charset=utf-8" }, + { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }, + ], + }, + ]; + }, }; -export default withNextIntl(nextConfig); +export default withSerwist(withNextIntl(nextConfig)); diff --git a/package.json b/package.json index 7e507c7..9638e46 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@serwist/next": "^9.5.3", "@tabler/icons-react": "^3.34.1", "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.89.0", @@ -82,6 +83,8 @@ "husky": "^9.1.7", "postcss": "^8.5.6", "prettier": "^3.6.2", + "serwist": "^9.5.3", + "sharp": "^0.34.5", "tailwindcss": "^4.1.18", "typescript": "^5" } diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..38f799ab7756d5fcbf36f48caef0935a752f2926 GIT binary patch literal 4019 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 Z{|7OFX3`Jl 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 RN5k;%gps9T$uXCx{|9L) { + diff --git a/src/widgets/ui/index.ts b/src/widgets/ui/index.ts index a929cd4..1cf0efd 100644 --- a/src/widgets/ui/index.ts +++ b/src/widgets/ui/index.ts @@ -6,3 +6,4 @@ export { ThemeLogo } from "./theme-logo"; export { ThemeToggle } from "./theme-toggle"; export { LocaleSwitcher } from "./locale-switcher"; +export { PwaInstallPrompt } from "./pwa-install-prompt"; diff --git a/src/widgets/ui/pwa-install-prompt.tsx b/src/widgets/ui/pwa-install-prompt.tsx new file mode 100644 index 0000000..f474a7e --- /dev/null +++ b/src/widgets/ui/pwa-install-prompt.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Download } from "lucide-react"; +import { Button } from "@/src/entities/button"; +import { useSidebar } from "@/src/entities/sidebar"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/src/entities/tooltip"; +import { useTranslations } from "next-intl"; + +// Тип для beforeinstallprompt события +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +/** + * PWA Install Prompt для всех устройств. + * Использует beforeinstallprompt для Android/Desktop и инструкции для iOS. + * Адаптируется под состояние сайдбара (expanded/collapsed). + */ +export function PwaInstallPrompt() { + const t = useTranslations("PwaInstallPrompt"); + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showIOSPrompt, setShowIOSPrompt] = useState(false); + const { state } = useSidebar(); + const isCollapsed = state === "collapsed"; + + useEffect(() => { + if (typeof window === "undefined") return; + + // Проверяем, не установлено ли уже приложение + const isStandalone = window.matchMedia("(display-mode: standalone)").matches; + if(isStandalone) return; + + // Обработка beforeinstallprompt для Android/Desktop + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + }; + + // Проверка iOS + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && + !(window as unknown as { MSStream?: boolean }).MSStream; + + if (isIOS) { + setShowIOSPrompt(true); + } + + window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + + return () => { + window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + }; + }, []); + + // Обработка установки через beforeinstallprompt + const handleInstallClick = async () => { + if (!deferredPrompt) return; + + // Показываем диалог установки + deferredPrompt.prompt(); + + // Ждем результата + const { outcome } = await deferredPrompt.userChoice; + + // Очищаем промпт после использования (браузер может показать его снова позже) + setDeferredPrompt(null); + }; + + // Если нет промпта для установки и не iOS - не показываем ничего + if (!deferredPrompt && !showIOSPrompt) return null; + + // Для iOS показываем инструкции + if (showIOSPrompt && !deferredPrompt) { + return ( +
+ {t("add-to-home-screen")}: {t("share")} + + → {t("to-home-screen")} +
+ ); + } + + // Для Android/Desktop показываем кнопку установки + if (deferredPrompt) { + const buttonContent = ( + + ); + + // Если сайдбар свернут, показываем кнопку с tooltip + if (isCollapsed) { + return ( + + + +
+ {buttonContent} +
+
+ +

{t("install-app")}

+
+
+
+ ); + } + + // Если сайдбар развернут, показываем кнопку с текстом + return ( +
+ {buttonContent} +
+ ); + } + + return null; +} diff --git a/tsconfig.json b/tsconfig.json index 705f5ce..6c5df96 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": ["dom", "dom.iterable", "esnext", "webworker"], + "types": ["@serwist/next/typings"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -29,5 +30,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "public/sw.js"] } From 01669f936312110a4a1f2b00c7c4908e0a81f1bc Mon Sep 17 00:00:00 2001 From: Kairgeldin Dmitry Date: Tue, 3 Feb 2026 14:34:56 +0500 Subject: [PATCH 02/63] feat: pwa --- app/layout.tsx | 6 +---- next.config.ts | 10 ++++++-- src/widgets/ui/pwa-install-prompt.tsx | 34 +++++++++++++-------------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index e9f0dea..9732223 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -47,11 +47,7 @@ export default function RootLayout({ - + diff --git a/next.config.ts b/next.config.ts index ed46b49..07817d4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -36,8 +36,14 @@ const nextConfig: NextConfig = { { source: "/sw.js", headers: [ - { key: "Content-Type", value: "application/javascript; charset=utf-8" }, - { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }, + { + key: "Content-Type", + value: "application/javascript; charset=utf-8", + }, + { + key: "Cache-Control", + value: "no-cache, no-store, must-revalidate", + }, ], }, ]; diff --git a/src/widgets/ui/pwa-install-prompt.tsx b/src/widgets/ui/pwa-install-prompt.tsx index f474a7e..368d46f 100644 --- a/src/widgets/ui/pwa-install-prompt.tsx +++ b/src/widgets/ui/pwa-install-prompt.tsx @@ -25,7 +25,8 @@ interface BeforeInstallPromptEvent extends Event { */ export function PwaInstallPrompt() { const t = useTranslations("PwaInstallPrompt"); - const [deferredPrompt, setDeferredPrompt] = useState(null); + const [deferredPrompt, setDeferredPrompt] = + useState(null); const [showIOSPrompt, setShowIOSPrompt] = useState(false); const { state } = useSidebar(); const isCollapsed = state === "collapsed"; @@ -34,8 +35,10 @@ export function PwaInstallPrompt() { if (typeof window === "undefined") return; // Проверяем, не установлено ли уже приложение - const isStandalone = window.matchMedia("(display-mode: standalone)").matches; - if(isStandalone) return; + const isStandalone = window.matchMedia( + "(display-mode: standalone)" + ).matches; + if (isStandalone) return; // Обработка beforeinstallprompt для Android/Desktop const handleBeforeInstallPrompt = (e: Event) => { @@ -44,9 +47,10 @@ export function PwaInstallPrompt() { }; // Проверка iOS - const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && + const isIOS = + /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as unknown as { MSStream?: boolean }).MSStream; - + if (isIOS) { setShowIOSPrompt(true); } @@ -54,7 +58,10 @@ export function PwaInstallPrompt() { window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); return () => { - window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + window.removeEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt + ); }; }, []); @@ -67,7 +74,7 @@ export function PwaInstallPrompt() { // Ждем результата const { outcome } = await deferredPrompt.userChoice; - + // Очищаем промпт после использования (браузер может показать его снова позже) setDeferredPrompt(null); }; @@ -80,8 +87,7 @@ export function PwaInstallPrompt() { return (
{t("add-to-home-screen")}: {t("share")} - - → {t("to-home-screen")} + → {t("to-home-screen")}
); } @@ -107,9 +113,7 @@ export function PwaInstallPrompt() { -
- {buttonContent} -
+
{buttonContent}

{t("install-app")}

@@ -120,11 +124,7 @@ export function PwaInstallPrompt() { } // Если сайдбар развернут, показываем кнопку с текстом - return ( -
- {buttonContent} -
- ); + return
{buttonContent}
; } return null; From 24838b9cfb761f1a8be8b602d191ea5e40a9b181 Mon Sep 17 00:00:00 2001 From: Kairgeldin Dmitry Date: Wed, 4 Feb 2026 22:13:51 +0500 Subject: [PATCH 03/63] feat: PWA + notifictaion --- .gitignore | 2 + app/[locale]/offline/page.tsx | 19 ++++ app/sw.ts | 65 ++++++++++++- app/~offline/page.tsx | 14 --- messages/kz.json | 5 + messages/ru.json | 5 + next.config.ts | 6 +- package.json | 6 +- src/features/settings/settings-content.tsx | 73 ++++++++++++++- src/shared/utils/push-notifications.ts | 101 +++++++++++++++++++++ src/widgets/ui/pwa-install-prompt.tsx | 3 - 11 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 app/[locale]/offline/page.tsx delete mode 100644 app/~offline/page.tsx create mode 100644 src/shared/utils/push-notifications.ts diff --git a/.gitignore b/.gitignore index 64946a6..62287bc 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates \ No newline at end of file diff --git a/app/[locale]/offline/page.tsx b/app/[locale]/offline/page.tsx new file mode 100644 index 0000000..2ea1ad0 --- /dev/null +++ b/app/[locale]/offline/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Button } from "@/src/entities"; +import { useTranslations } from "next-intl"; + +export default function OfflinePage() { + const t = useTranslations("Offline"); + + return ( +
+
+
📡
+

{t("title")}

+

{t("description")}

+ +
+
+ ); +} diff --git a/app/sw.ts b/app/sw.ts index 3f0b5be..fa5d99a 100644 --- a/app/sw.ts +++ b/app/sw.ts @@ -19,11 +19,11 @@ const serwist = new Serwist({ clientsClaim: true, navigationPreload: true, runtimeCaching: defaultCache, - // Офлайн: для document показываем страницу /~offline + // Офлайн: для document показываем страницу /offline fallbacks: { entries: [ { - url: "/~offline", + url: "/:locale/offline", matcher({ request }) { return request.destination === "document"; }, @@ -32,4 +32,65 @@ const serwist = new Serwist({ }, }); +// 🔔 Обработка Push-событий +self.addEventListener("push", (event: PushEvent) => { + console.log("push", event); + if (event.data) { + try { + const pushData = event.data.json(); + console.log("pushData", pushData); + const notificationOptions: NotificationOptions & { + actions?: Array<{ action: string; title: string; icon?: string }>; + image?: string; + vibrate?: number[]; + timestamp?: number; + } = { + body: pushData.body || "Новое сообщение", + icon: pushData.icon || "/icon-192x192.png", + badge: pushData.badge || "/badge-72x72.png", + image: pushData.image, + tag: pushData.tag || "default", + data: pushData.data, + requireInteraction: pushData.requireInteraction || false, + silent: pushData.silent || false, + vibrate: pushData.vibrate || [200, 100, 200], + timestamp: Date.now(), + actions: pushData.actions || [], + }; + console.log("notificationOptions", notificationOptions); + event.waitUntil( + self.registration + .showNotification(pushData.title, notificationOptions) + .then(notification => { + console.log("notification", notification); + }) + ); + } catch (error) { + console.error("Push notification error:", error); + } + } +}); + +// 🖱️ Обработка кликов по уведомлениям +self.addEventListener("notificationclick", (event: NotificationEvent) => { + event.notification.close(); + + const urlToOpen = event.notification.data?.url || "/"; + + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then(clientList => { + // Если есть открытое окно - фокусируемся на нем + for (const client of clientList) { + if (client.url.includes(urlToOpen) && "focus" in client) { + return client.focus(); + } + } + // Иначе открываем новое + return self.clients.openWindow(urlToOpen); + }) + ); +}); + serwist.addEventListeners(); diff --git a/app/~offline/page.tsx b/app/~offline/page.tsx deleted file mode 100644 index 33096ad..0000000 --- a/app/~offline/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Страница-заглушка при офлайн (PWA fallback). - * Показывается Serwist, когда запрос document не может быть выполнен из сети/кэша. - */ -export default function OfflinePage() { - return ( -
-

Нет подключения

-

- Проверьте интернет и обновите страницу. -

-
- ); -} diff --git a/messages/kz.json b/messages/kz.json index 70829b4..8dbeb19 100644 --- a/messages/kz.json +++ b/messages/kz.json @@ -1,5 +1,10 @@ { "LocaleLayout": { "title": "Bagsy — жазбаларды басқару" }, + "Offline": { + "title": "Байланыс жоқ", + "description": "Интернет байланысын тексеріңіз. Байланыс қалпына келгенде бет автоматты түрде ашылады.", + "retry": "Қайталау" + }, "Sidebar": { "sidebar": "Боковое меню", "sidebarDescription": "Боковое меню көрсетеді.", diff --git a/messages/ru.json b/messages/ru.json index 2aed92e..e7f4bbc 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1,5 +1,10 @@ { "LocaleLayout": { "title": "Bagsy — управление записями" }, + "Offline": { + "title": "Нет подключения", + "description": "Проверьте интернет-соединение. Страница откроется автоматически, когда связь восстановится.", + "retry": "Повторить попытку" + }, "Sidebar": { "sidebar": "Боковое меню", "sidebarDescription": "Отображает боковое меню.", diff --git a/next.config.ts b/next.config.ts index 07817d4..aed9ea1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,7 @@ import withSerwistInit from "@serwist/next"; // Плагин next-intl const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); +const locales = ["ru", "kz"]; // Ревизия для precache (при обновлении SW инвалидируется кэш) const revision = crypto.randomUUID?.() ?? Date.now().toString(); @@ -12,7 +13,10 @@ const revision = crypto.randomUUID?.() ?? Date.now().toString(); const withSerwist = withSerwistInit({ swSrc: "app/sw.ts", swDest: "public/sw.js", - additionalPrecacheEntries: [{ url: "/~offline", revision }], + additionalPrecacheEntries: locales.map(locale => ({ + url: `/${locale}/offline`, + revision, + })), register: true, scope: "/", disable: process.env.NODE_ENV !== "production", diff --git a/package.json b/package.json index 9638e46..b78233f 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "dev-network": "next dev --hostname 10.200.6.202 --port 3010", + "dev": "next dev --webpack", + "dev-network": "next dev --hostname 192.168.1.28", "dev-all": "next dev --hostname 0.0.0.0 --port 3000", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "eslint", "lint:fix": "eslint --fix", diff --git a/src/features/settings/settings-content.tsx b/src/features/settings/settings-content.tsx index ffbb76f..ed5db4b 100644 --- a/src/features/settings/settings-content.tsx +++ b/src/features/settings/settings-content.tsx @@ -1,12 +1,64 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { LocaleSwitcher, ThemeToggle } from "@/src/widgets"; +import { BellRing, BellOff } from "lucide-react"; +import { PushNotificationManager } from "@/src/shared/utils/push-notifications"; +import { Button } from "@/src/entities"; const SettingsContent: React.FC = () => { const t = useTranslations("Settings"); + const [isSupported, setIsSupported] = useState(false); + const [isSubscribed, setIsSubscribed] = useState(false); + const [permission, setPermission] = + useState("default"); + + useEffect(() => { + setIsSupported("Notification" in window && "serviceWorker" in navigator); + setPermission(Notification.permission); + + checkSubscription(); + }, []); + + async function checkSubscription() { + if (!isSupported) return; + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + setIsSubscribed(!!subscription); + } + + async function handleToggle() { + console.log("--------------------------------"); + console.log("handleToggle"); + console.log("isSubscribed", isSubscribed); + console.log("permission", permission); + console.log("isSupported", isSupported); + console.log("--------------------------------"); + const manager = new PushNotificationManager( + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY! + ); + + if (isSubscribed) { + await manager.unsubscribe(); + setIsSubscribed(false); + } else { + try { + await manager.subscribe(); + setIsSubscribed(true); + setPermission("granted"); + } catch (error) { + console.error("Subscription failed:", error); + } + } + } + + console.log("isSubscribed", isSubscribed); + console.log("permission", permission); + console.log("isSupported", isSupported); + return (
@@ -25,8 +77,25 @@ const SettingsContent: React.FC = () => {
-
+

{t("notifications")}

+ {!isSupported &&

Ваш браузер не поддерживает уведомления

} + + {isSupported && permission === "denied" && ( +

+ Вы заблокировали уведомления. Разрешите их в настройках браузера +

+ )} + + {isSupported && permission !== "denied" && ( + + )}
diff --git a/src/shared/utils/push-notifications.ts b/src/shared/utils/push-notifications.ts new file mode 100644 index 0000000..4652a1c --- /dev/null +++ b/src/shared/utils/push-notifications.ts @@ -0,0 +1,101 @@ +export class PushNotificationManager { + private vapidPublicKey: string; + + constructor(vapidPublicKey: string) { + this.vapidPublicKey = vapidPublicKey; + } + + // Запрос разрешения и подписка + async subscribe() { + // 1. Проверяем поддержку + if (!("serviceWorker" in navigator)) { + throw new Error("Service Workers not supported"); + } + + if (!("PushManager" in window)) { + throw new Error("Push API not supported"); + } + + // 2. Запрашиваем разрешение + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + throw new Error("Permission denied"); + } + + // 3. Получаем SW registration + const registration = await navigator.serviceWorker.ready; + + // 4. Подписываемся на push + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey), + }); + console.log("subscription", subscription); + // 5. Отправляем подписку на бэк + + try { + await fetch("/api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subscription: subscription.toJSON(), + deviceType: this.getDeviceType(), + }), + }); + } catch (error) { + console.error("Subscription failed:", error); + } + + return subscription; + } + + async unsubscribe() { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await subscription.unsubscribe(); + + // Удаляем с бэка + try { + await fetch("/api/push/unsubscribe", { + method: "POST", + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + } catch (error) { + console.error("Unsubscription failed:", error); + } + } + } + + private urlBase64ToUint8Array(base64String: string) { + // Конвертация VAPID ключа + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, "+") + .replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + private getDeviceType() { + const ua = navigator.userAgent; + if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) { + return "tablet"; + } + if ( + /Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test( + ua + ) + ) { + return "mobile"; + } + return "desktop"; + } +} diff --git a/src/widgets/ui/pwa-install-prompt.tsx b/src/widgets/ui/pwa-install-prompt.tsx index 368d46f..6430eb4 100644 --- a/src/widgets/ui/pwa-install-prompt.tsx +++ b/src/widgets/ui/pwa-install-prompt.tsx @@ -79,9 +79,6 @@ export function PwaInstallPrompt() { setDeferredPrompt(null); }; - // Если нет промпта для установки и не iOS - не показываем ничего - if (!deferredPrompt && !showIOSPrompt) return null; - // Для iOS показываем инструкции if (showIOSPrompt && !deferredPrompt) { return ( From 20ebed18047ce21cc4e13b8971b3a30377f567c0 Mon Sep 17 00:00:00 2001 From: Kairgeldin Dmitry Date: Fri, 6 Feb 2026 17:51:04 +0500 Subject: [PATCH 04/63] upd: refresh to main page --- app/[locale]/offline/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/[locale]/offline/page.tsx b/app/[locale]/offline/page.tsx index 2ea1ad0..37ad655 100644 --- a/app/[locale]/offline/page.tsx +++ b/app/[locale]/offline/page.tsx @@ -2,9 +2,11 @@ import { Button } from "@/src/entities"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; export default function OfflinePage() { const t = useTranslations("Offline"); + const router = useRouter(); return (
@@ -12,7 +14,7 @@ export default function OfflinePage() {
📡

{t("title")}

{t("description")}

- +
); From bac0f9f44f73b100799fcf5c650391eccd0848a8 Mon Sep 17 00:00:00 2001 From: Kairgeldin Dmitry Date: Tue, 24 Feb 2026 20:51:07 +0500 Subject: [PATCH 05/63] feat: schedule --- app/[locale]/(sidebar)/schedule/page.tsx | 6 + messages/kz.json | 59 +++ messages/ru.json | 59 +++ .../schedule/api/use-month-schedule.ts | 96 +++++ src/features/schedule/index.ts | 8 + src/features/schedule/schedule-content.tsx | 351 ++++++++++++++++++ src/features/schedule/schedule-header.tsx | 29 ++ .../schedule/schedule-page-client.tsx | 27 ++ .../schedule/schedule-scope-context.tsx | 36 ++ src/features/schedule/ui/month-grid.tsx | 186 ++++++++++ src/features/schedule/ui/schedule-editor.tsx | 233 ++++++++++++ src/features/schedule/ui/schedule-presets.tsx | 167 +++++++++ src/features/schedule/ui/time-range-row.tsx | 75 ++++ src/shared/hooks/index.ts | 3 + src/shared/hooks/use-schedule-permissions.ts | 72 ++++ src/shared/types/index.ts | 2 + src/shared/types/schedule.ts | 37 ++ src/widgets/navigation/app-sidebar.tsx | 6 + 18 files changed, 1452 insertions(+) create mode 100644 app/[locale]/(sidebar)/schedule/page.tsx create mode 100644 src/features/schedule/api/use-month-schedule.ts create mode 100644 src/features/schedule/index.ts create mode 100644 src/features/schedule/schedule-content.tsx create mode 100644 src/features/schedule/schedule-header.tsx create mode 100644 src/features/schedule/schedule-page-client.tsx create mode 100644 src/features/schedule/schedule-scope-context.tsx create mode 100644 src/features/schedule/ui/month-grid.tsx create mode 100644 src/features/schedule/ui/schedule-editor.tsx create mode 100644 src/features/schedule/ui/schedule-presets.tsx create mode 100644 src/features/schedule/ui/time-range-row.tsx create mode 100644 src/shared/hooks/use-schedule-permissions.ts create mode 100644 src/shared/types/schedule.ts diff --git a/app/[locale]/(sidebar)/schedule/page.tsx b/app/[locale]/(sidebar)/schedule/page.tsx new file mode 100644 index 0000000..1eb2ef9 --- /dev/null +++ b/app/[locale]/(sidebar)/schedule/page.tsx @@ -0,0 +1,6 @@ +import { SchedulePageClient } from "@/src/features/schedule"; + +/** Страница графика: помесячное расписание точки/мастера, пресеты и перерывы. */ +export default function SchedulePage() { + return ; +} diff --git a/messages/kz.json b/messages/kz.json index 8dbeb19..fccb9c7 100644 --- a/messages/kz.json +++ b/messages/kz.json @@ -10,6 +10,7 @@ "sidebarDescription": "Боковое меню көрсетеді.", "Main": { "calendar": "Күнтізбе", + "schedule": "Жұмыс графигі", "clients": "Клиенттері", "services": "Қызметтер", "staff": "Жұмысшылар", @@ -492,5 +493,63 @@ "serviceIdRequired": "Қызмет ID міндетті", "masterPhoneRequired": "Шеберді таңдаңыз" } + }, + "Schedule": { + "title": "Жұмыс графигі", + "description": "Ай күндері бойынша жұмыс графигін баптау", + "noAccess": "Графикті баптауға қол жетімсіз", + "mySchedule": "Менің графигім", + "pointSchedule": "Нүкте графигі", + "monthGridTitle": "Ай күндері", + "editorTitle": "Таңдалған күндердің графигі", + "selectedCount": "Таңдалды: {count}", + "clearSelection": "Тазалау", + "Weekdays": { + "mon": "Дс", + "tue": "Сс", + "wed": "Ср", + "thu": "Бс", + "fri": "Жм", + "sat": "Сн", + "sun": "Жс" + }, + "MonthNav": { + "prev": "Алдыңғы ай", + "next": "Келесі ай", + "today": "Бүгін" + }, + "Header": { + "title": "Жұмыс графигі", + "mySchedule": "Менің графигім", + "pointSchedule": "Нүкте графигі", + "fixedBadge": "Бекітілген", + "mixedBadge": "Аралас", + "scheduleTypeFixedHint": "Нүкте графигі бекітілген, сіз оны өзгерте алмайсыз", + "scheduleTypeMixedHint": "Нүкте графигі аралас — шебер әр айға өз графигін өзі орнатады" + }, + "save": "Сақтау", + "Editor": { + "selectDaysHint": "Графикті орнату үшін күнтізбеден күндерді таңдаңыз", + "dayIsClosed": "Күн демалыс деп белгіленген", + "makeOpen": "Жұмыс күні ету", + "makeClosed": "Демалыс күні ету", + "open": "Ашық", + "closed": "Жабық", + "workHours": "Жұмыс сағаттары", + "breaks": "Үзілістер", + "noBreaks": "Үзілістер жоқ", + "from": "Бастап", + "to": "Дейін", + "addBreak": "Үзіліс қосу", + "addWorkRange": "Интервал қосу", + "mixedTimesWarning": "Таңдалған күндердің уақыты әртүрлі. Өзгерістер барлық таңдалған күндерге қолданылады." + }, + "Presets": { + "selectAll": "Барлығын таңдау", + "weekdays": "5/2 (Дс–Жм)", + "evenDays": "Жұп күндер", + "oddDays": "Тақ күндер", + "addBreakToAll": "Барлығына үзіліс" + } } } diff --git a/messages/ru.json b/messages/ru.json index e7f4bbc..fdd6ec6 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10,6 +10,7 @@ "sidebarDescription": "Отображает боковое меню.", "Main": { "calendar": "Календарь", + "schedule": "Расписание", "clients": "Клиенты", "services": "Услуги", "staff": "Сотрудники", @@ -492,5 +493,63 @@ "serviceIdRequired": "ID услуги обязателен", "masterPhoneRequired": "Выберите мастера" } + }, + "Schedule": { + "title": "График", + "description": "Настройка графика работы по дням месяца", + "noAccess": "Нет доступа к настройке графика", + "mySchedule": "Мой график", + "pointSchedule": "График точки", + "monthGridTitle": "Дни месяца", + "editorTitle": "График выбранных дней", + "selectedCount": "Выбрано: {count}", + "clearSelection": "Сбросить", + "Weekdays": { + "mon": "Пн", + "tue": "Вт", + "wed": "Ср", + "thu": "Чт", + "fri": "Пт", + "sat": "Сб", + "sun": "Вс" + }, + "MonthNav": { + "prev": "Предыдущий месяц", + "next": "Следующий месяц", + "today": "Сегодня" + }, + "Header": { + "title": "График", + "mySchedule": "Мой график", + "pointSchedule": "График точки", + "fixedBadge": "Фиксированный", + "mixedBadge": "Смешанный", + "scheduleTypeFixedHint": "График точки фиксированный, вы не можете его менять", + "scheduleTypeMixedHint": "График точки смешанный — мастер настраивает свой график на каждый месяц" + }, + "save": "Сохранить", + "Editor": { + "selectDaysHint": "Выберите дни в календаре, чтобы задать график", + "dayIsClosed": "День отмечен как выходной", + "makeOpen": "Сделать рабочим", + "makeClosed": "Сделать выходным", + "open": "Открыто", + "closed": "Закрыто", + "workHours": "Рабочие часы", + "breaks": "Перерывы", + "noBreaks": "Нет перерывов", + "from": "С", + "to": "До", + "addBreak": "Добавить перерыв", + "addWorkRange": "Добавить интервал", + "mixedTimesWarning": "У выбранных дней разное время. Изменения будут применены ко всем выбранным дням." + }, + "Presets": { + "selectAll": "Выбрать все", + "weekdays": "5/2 (Пн–Пт)", + "evenDays": "Чётные", + "oddDays": "Нечётные", + "addBreakToAll": "Перерыв всем" + } } } diff --git a/src/features/schedule/api/use-month-schedule.ts b/src/features/schedule/api/use-month-schedule.ts new file mode 100644 index 0000000..6f07a43 --- /dev/null +++ b/src/features/schedule/api/use-month-schedule.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { MonthSchedule, ScheduleScope } from "@/src/shared/types/schedule"; +import { + getDaysInMonth, + startOfMonth, + addMonths, + subMonths, +} from "date-fns"; + +const QUERY_KEY_PREFIX = "month-schedule" as const; + +/** Пустое расписание одного дня (закрыто, без интервалов). */ +function emptyDaySchedule() { + return { + isClosed: true, + workRanges: [] as { start: string; end: string }[], + breaks: [] as { start: string; end: string }[], + }; +} + +/** Строит пустой MonthSchedule для указанного месяца (дни 1..N). */ +export function buildEmptyMonthSchedule(year: number, month: number): MonthSchedule { + const daysInMonth = getDaysInMonth(new Date(year, month, 1)); + const result: MonthSchedule = {}; + for (let d = 1; d <= daysInMonth; d++) { + result[d] = emptyDaySchedule(); + } + return result; +} + +/** TODO: заменить на реальный API — загрузка графика точки/мастера на месяц. */ +async function fetchMonthSchedule( + _scope: ScheduleScope, + _year: number, + _month: number +): Promise { + await new Promise((r) => setTimeout(r, 200)); + const daysInMonth = getDaysInMonth(new Date(_year, _month, 1)); + const result: MonthSchedule = {}; + for (let d = 1; d <= daysInMonth; d++) { + result[d] = emptyDaySchedule(); + } + return result; +} + +/** TODO: заменить на реальный API — сохранение графика на месяц. */ +async function saveMonthSchedule( + _scope: ScheduleScope, + _year: number, + _month: number, + data: MonthSchedule +): Promise { + await new Promise((r) => setTimeout(r, 300)); + void data; +} + +export function useMonthSchedule(scope: ScheduleScope, date: Date) { + const year = date.getFullYear(); + const month = date.getMonth(); + const queryKey = [QUERY_KEY_PREFIX, scope, year, month] as const; + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey, + queryFn: () => fetchMonthSchedule(scope, year, month), + placeholderData: () => buildEmptyMonthSchedule(year, month), + }); + + const mutation = useMutation({ + mutationFn: (data: MonthSchedule) => + saveMonthSchedule(scope, year, month, data), + onSuccess: (_, data) => { + queryClient.setQueryData(queryKey, data); + }, + }); + + const monthStart = startOfMonth(date); + const prevMonth = subMonths(monthStart, 1); + const nextMonth = addMonths(monthStart, 1); + + return { + data: query.data ?? buildEmptyMonthSchedule(year, month), + isLoading: query.isLoading, + error: query.error, + refetch: query.refetch, + save: mutation.mutateAsync, + isSaving: mutation.isPending, + year, + month, + prevMonth, + nextMonth, + daysInMonth: getDaysInMonth(date), + }; +} diff --git a/src/features/schedule/index.ts b/src/features/schedule/index.ts new file mode 100644 index 0000000..16bf7cd --- /dev/null +++ b/src/features/schedule/index.ts @@ -0,0 +1,8 @@ +export { ScheduleHeader } from "./schedule-header"; +export { ScheduleContent } from "./schedule-content"; +export { SchedulePageClient } from "./schedule-page-client"; +export { ScheduleScopeProvider, useScheduleScope } from "./schedule-scope-context"; +export { MonthGrid } from "./ui/month-grid"; +export { ScheduleEditor } from "./ui/schedule-editor"; +export { SchedulePresets } from "./ui/schedule-presets"; +export { useMonthSchedule, buildEmptyMonthSchedule } from "./api/use-month-schedule"; diff --git a/src/features/schedule/schedule-content.tsx b/src/features/schedule/schedule-content.tsx new file mode 100644 index 0000000..64a819e --- /dev/null +++ b/src/features/schedule/schedule-content.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { ChevronLeft, ChevronRight, Loader2, Save } from "lucide-react"; +import { + Alert, + AlertDescription, + Button, + Tabs, + TabsList, + TabsTrigger, +} from "@/src/entities"; +import { Card, CardContent } from "@/src/entities"; +import { useSchedulePermissions } from "@/src/shared/hooks/use-schedule-permissions"; +import { useScheduleScope } from "./schedule-scope-context"; +import { useMonthSchedule } from "./api/use-month-schedule"; +import type { DaySchedule, MonthSchedule } from "@/src/shared/types/schedule"; +import { MonthGrid } from "./ui/month-grid"; +import { ScheduleEditor } from "./ui/schedule-editor"; +import { SchedulePresets } from "./ui/schedule-presets"; +import { format } from "date-fns"; +import { ru, kk } from "date-fns/locale"; +import { useLocale } from "next-intl"; +import { getDay } from "date-fns"; + +/** Дефолтное расписание для нового рабочего дня. */ +const DEFAULT_OPEN_DAY: DaySchedule = { + isClosed: false, + workRanges: [{ start: "09:00", end: "18:00" }], + breaks: [], +}; + +export function ScheduleContent() { + const t = useTranslations("Schedule"); + const locale = useLocale(); + const dateFnsLocale = locale === "kz" ? kk : ru; + + /* Права: мок — заменить на реальные данные с API. */ + const permissions = useSchedulePermissions({ + userFlags: { can_work: true, can_manage_point_schedule: true }, + pointContext: { schedule_type: "mixed" }, + }); + + const { activeScope, setActiveScope } = useScheduleScope(); + const [currentMonth, setCurrentMonth] = useState(() => new Date()); + const [selectedDays, setSelectedDays] = useState([]); + + const { + data: serverSchedule, + save, + isSaving, + isLoading, + prevMonth, + nextMonth, + daysInMonth, + } = useMonthSchedule(activeScope, currentMonth); + + /* Локальный state расписания (изменения копятся здесь до нажатия "Сохранить"). */ + const [localSchedule, setLocalSchedule] = + useState(serverSchedule); + const [isDirty, setIsDirty] = useState(false); + + /* Дни, авто-открытые при клике (для отката при deselect). */ + const autoOpenedDaysRef = useRef>(new Set()); + + /* Синхронизация с сервером при загрузке / смене месяца. */ + const prevServerRef = useRef(serverSchedule); + useEffect(() => { + if (serverSchedule !== prevServerRef.current) { + setLocalSchedule(serverSchedule); + setIsDirty(false); + prevServerRef.current = serverSchedule; + /* Очищаем сет авто-открытых дней при смене месяца / загрузке. */ + autoOpenedDaysRef.current.clear(); + } + }, [serverSchedule]); + + const readOnly = + (activeScope === "staff" && permissions.isPointScheduleFixed) || + (activeScope === "point" && !permissions.canEditPointSchedule); + + /* Обновить локальное расписание (без сохранения на бэк). */ + const updateLocal = useCallback( + (updater: (prev: MonthSchedule) => MonthSchedule) => { + setLocalSchedule(prev => { + const next = updater(prev); + setIsDirty(true); + return next; + }); + }, + [] + ); + + /* Выбор / отмена дня. При выборе — авто-открытие; при deselect — откат если не менялось. */ + const toggleDay = useCallback((day: number) => { + setSelectedDays(prev => { + const isDeselecting = prev.includes(day); + if (isDeselecting) { + /* Откат авто-открытого дня: возвращаем isClosed если пользователь не редактировал. */ + if (autoOpenedDaysRef.current.has(day)) { + autoOpenedDaysRef.current.delete(day); + setLocalSchedule(p => ({ ...p, [day]: { isClosed: true, workRanges: [], breaks: [] } })); + } + return prev.filter(d => d !== day); + } + /* Авто-открытие: если день закрыт или не существует, ставим дефолтное расписание. */ + setLocalSchedule(p => { + const dayData = p[day]; + if (!dayData || dayData.isClosed) { + autoOpenedDaysRef.current.add(day); + setIsDirty(true); + return { ...p, [day]: { ...DEFAULT_OPEN_DAY } }; + } + return p; + }); + return [...prev, day].sort((a, b) => a - b); + }); + }, []); + + /* Shift+клик — диапазон. Авто-открытие для всех новых дней. */ + const rangeSelect = useCallback( + (from: number, to: number) => { + const newDays = new Set(selectedDays); + for (let d = from; d <= to; d++) newDays.add(d); + setSelectedDays(Array.from(newDays).sort((a, b) => a - b)); + /* Авто-открытие всех закрытых дней в диапазоне. */ + setLocalSchedule(prev => { + let changed = false; + const next = { ...prev }; + for (let d = from; d <= to; d++) { + if (next[d]?.isClosed) { + next[d] = { ...DEFAULT_OPEN_DAY }; + changed = true; + } + } + if (changed) setIsDirty(true); + return changed ? next : prev; + }); + }, + [selectedDays] + ); + + /* Применить изменение ко всем выбранным дням (локально). */ + const applyToSelected = useCallback( + (updater: (draft: DaySchedule) => DaySchedule) => { + /* Пользователь явно отредактировал — убираем из авто-открытых (не откатывать при deselect). */ + selectedDays.forEach(day => autoOpenedDaysRef.current.delete(day)); + updateLocal(prev => { + const next = { ...prev }; + selectedDays.forEach(day => { + const draft = next[day] ?? { + isClosed: true, + workRanges: [], + breaks: [], + }; + next[day] = updater(draft); + }); + return next; + }); + }, + [selectedDays, updateLocal] + ); + + /* Применить расписание к конкретным дням (для пресетов). */ + const applyToDays = useCallback( + (days: number[], schedule: DaySchedule) => { + updateLocal(prev => { + const next = { ...prev }; + days.forEach(day => { + next[day] = { ...schedule }; + }); + return next; + }); + }, + [updateLocal] + ); + + /* Сохранить на бэк. */ + const handleSave = useCallback(async () => { + await save(localSchedule); + setIsDirty(false); + }, [save, localSchedule]); + + /* Перейти к сегодня. */ + const goToday = useCallback(() => { + const now = new Date(); + setCurrentMonth(now); + setSelectedDays([now.getDate()]); + }, []); + + /* Вычисляем firstDayOffset для пресета 5/2. */ + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); + const firstJsDay = getDay(new Date(year, month, 1)); // 0=Вс + const firstDayOffset = firstJsDay === 0 ? 6 : firstJsDay - 1; // 0=Пн + + if (permissions.scopeOptions.length === 0) { + return ( +
{t("noAccess")}
+ ); + } + + return ( +
+ {/* Табы: мой график / график точки */} + {permissions.scopeOptions.length > 1 && ( + setActiveScope(v as "point" | "staff")} + > + + {t("mySchedule")} + {t("pointSchedule")} + + + )} + + {/* Информер при фиксированном графике точки. */} + {readOnly && permissions.isPointScheduleFixed && ( + + + {t("Header.scheduleTypeFixedHint")} + + + )} + + {/* Навигация по месяцам */} +
+ +
+ + {format(currentMonth, "LLLL yyyy", { locale: dateFnsLocale })} + + +
+ +
+ + {isLoading ? ( +
+ +
+ ) : ( + /* Двухколоночный layout на десктопе */ +
+ {/* Левая колонка — календарь + пресеты */} +
+ + + + {/* Подсказка выбора */} + {selectedDays.length > 0 && ( +
+ + {t("selectedCount", { count: selectedDays.length })} + + +
+ )} +
+
+ + {/* Пресеты — под календарём */} + {!readOnly && ( + + )} +
+ + {/* Правая колонка — редактор + кнопка сохранить */} +
+ + + + + + + {/* Кнопка сохранить */} + {isDirty && !readOnly && ( +
+ +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/features/schedule/schedule-header.tsx b/src/features/schedule/schedule-header.tsx new file mode 100644 index 0000000..ca094bf --- /dev/null +++ b/src/features/schedule/schedule-header.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Separator, SidebarTrigger } from "@/src/entities"; + +/** Мок прав и точки — когда будет API, передавать сюда данные пользователя и точки. */ +export interface ScheduleHeaderProps { + userFlags?: { + can_work?: boolean; + can_manage_point_schedule?: boolean; + } | null; + pointContext?: { schedule_type: "fixed" | "mixed" } | null; +} + +/** + * Заголовок страницы графика: переключатель «Мой график» / «График точки» и бейдж типа графика (fixed/mixed). + */ +export function ScheduleHeader() { + const t = useTranslations("Schedule.Header"); + return ( +
+
+ + +

{t("title")}

+
+
+ ); +} diff --git a/src/features/schedule/schedule-page-client.tsx b/src/features/schedule/schedule-page-client.tsx new file mode 100644 index 0000000..27a8ccd --- /dev/null +++ b/src/features/schedule/schedule-page-client.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useSchedulePermissions } from "@/src/shared/hooks/use-schedule-permissions"; +import { ScheduleScopeProvider } from "./schedule-scope-context"; +import { ScheduleHeader } from "./schedule-header"; +import { ScheduleContent } from "./schedule-content"; + +/** Мок: когда с API появятся can_work, can_manage_point_schedule и schedule_type точки — подставить данные из useCurrentUser и точки. */ +const MOCK_USER_FLAGS = { can_work: true, can_manage_point_schedule: true }; +const MOCK_POINT_CONTEXT = { schedule_type: "mixed" as const }; + +/** + * Клиентская обёртка страницы графика: провайдер scope и права. + */ +export function SchedulePageClient() { + const permissions = useSchedulePermissions({ + userFlags: MOCK_USER_FLAGS, + pointContext: MOCK_POINT_CONTEXT, + }); + + return ( + + + + + ); +} diff --git a/src/features/schedule/schedule-scope-context.tsx b/src/features/schedule/schedule-scope-context.tsx new file mode 100644 index 0000000..0182bca --- /dev/null +++ b/src/features/schedule/schedule-scope-context.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { createContext, useContext, useState, useMemo } from "react"; +import type { ScheduleScope } from "@/src/shared/types/schedule"; + +type ScheduleScopeContextValue = { + activeScope: ScheduleScope; + setActiveScope: (s: ScheduleScope) => void; +}; + +const ScheduleScopeContext = createContext(null); + +export function useScheduleScope(): ScheduleScopeContextValue { + const ctx = useContext(ScheduleScopeContext); + if (!ctx) throw new Error("useScheduleScope must be used within ScheduleScopeProvider"); + return ctx; +} + +export function ScheduleScopeProvider({ + defaultScope, + children, +}: { + defaultScope: ScheduleScope; + children: React.ReactNode; +}) { + const [activeScope, setActiveScope] = useState(defaultScope); + const value = useMemo( + () => ({ activeScope, setActiveScope }), + [activeScope] + ); + return ( + + {children} + + ); +} diff --git a/src/features/schedule/ui/month-grid.tsx b/src/features/schedule/ui/month-grid.tsx new file mode 100644 index 0000000..0bfbf94 --- /dev/null +++ b/src/features/schedule/ui/month-grid.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { getDay, isToday } from "date-fns"; +import { cn } from "@/src/shared/utils/styles"; +import { useTranslations } from "next-intl"; +import type { MonthSchedule } from "@/src/shared/types/schedule"; +import { formatScheduleTime } from "@/src/shared/utils/formater"; + +export interface MonthGridProps { + /** Текущий месяц (для подсветки «сегодня»). */ + currentMonth: Date; + /** Количество дней в месяце (1..31). */ + daysInMonth: number; + /** Выбранные дни (номера 1..31). */ + selectedDays: number[]; + /** Полное расписание месяца (для отображения часов в ячейке). */ + monthSchedule: MonthSchedule; + onToggleDay: (day: number) => void; + onRangeSelect?: (from: number, to: number) => void; + readOnly?: boolean; +} + +/** Дни недели начиная с понедельника (ISO). */ +const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; + +/** Получить смещение первого дня месяца (0=Пн, 6=Вс). */ +function getFirstDayOffset(year: number, month: number): number { + const jsDay = getDay(new Date(year, month, 1)); // 0=Вс, 1=Пн...6=Сб + return jsDay === 0 ? 6 : jsDay - 1; // преобразуем в ISO (0=Пн) +} + +/** + * Календарная сетка дней месяца с заголовками дней недели. + * Клик — toggle дня; Shift+клик — выбор диапазона. + * На десктопе показывает рабочие часы внутри ячейки. + */ +export function MonthGrid({ + currentMonth, + daysInMonth, + selectedDays, + monthSchedule, + onToggleDay, + onRangeSelect, + readOnly = false, +}: MonthGridProps) { + const t = useTranslations("Schedule.Weekdays"); + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); + + /* Смещение первого дня (пустые ячейки перед 1-м числом). */ + const firstDayOffset = useMemo( + () => getFirstDayOffset(year, month), + [year, month] + ); + + const handleClick = useCallback( + (day: number, e: React.MouseEvent) => { + if (readOnly) return; + if (e.shiftKey && onRangeSelect && selectedDays.length > 0) { + const last = Math.max(...selectedDays); + const from = Math.min(last, day); + const to = Math.max(last, day); + onRangeSelect(from, to); + } else { + onToggleDay(day); + } + }, + [readOnly, onToggleDay, onRangeSelect, selectedDays] + ); + + return ( +
+ {/* Заголовки дней недели */} + {WEEKDAYS.map((wd, i) => ( +
= 5 && "text-muted-foreground/60" + )} + > + {t(wd)} +
+ ))} + + {/* Пустые ячейки перед первым днём месяца */} + {Array.from({ length: firstDayOffset }, (_, i) => ( +
+ ))} + + {/* Ячейки дней */} + {Array.from({ length: daysInMonth }, (_, i) => i + 1).map(day => { + const date = new Date(year, month, day); + const isSelected = selectedDays.includes(day); + const daySchedule = monthSchedule[day]; + const hasSchedule = + daySchedule && + !daySchedule.isClosed && + (daySchedule.workRanges?.length ?? 0) > 0; + const isCurrentDay = isToday(date); + /* Суббота/воскресенье */ + const dayOfWeek = (firstDayOffset + day - 1) % 7; // 0=Пн...6=Вс + const isWeekend = dayOfWeek >= 5; + + /* Текст рабочих часов для десктопной ячейки */ + const workTimeText = + hasSchedule && daySchedule.workRanges[0] + ? `${formatScheduleTime(daySchedule.workRanges[0].start)}–${formatScheduleTime(daySchedule.workRanges[0].end)}` + : null; + + return ( + + ); + })} +
+ ); +} diff --git a/src/features/schedule/ui/schedule-editor.tsx b/src/features/schedule/ui/schedule-editor.tsx new file mode 100644 index 0000000..a3ff754 --- /dev/null +++ b/src/features/schedule/ui/schedule-editor.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { AlertTriangle, Plus, X } from "lucide-react"; +import { Label } from "@/src/entities/label"; +import { Button } from "@/src/entities"; +import { Separator } from "@/src/entities"; +import { TimeRangeRow } from "./time-range-row"; +import type { + DaySchedule, + MonthSchedule, + TimeRange, +} from "@/src/shared/types/schedule"; + +export interface ScheduleEditorProps { + selectedDays: number[]; + monthSchedule: MonthSchedule; + onApplyToSelected: (updater: (draft: DaySchedule) => DaySchedule) => void; + readOnly?: boolean; +} + +const defaultWorkRange: TimeRange = { start: "09:00", end: "18:00" }; + +export function ScheduleEditor({ + selectedDays, + monthSchedule, + onApplyToSelected, + readOnly = false, +}: ScheduleEditorProps) { + const t = useTranslations("Schedule.Editor"); + + /* Берём первый выбранный день как репрезентативный. */ + const rep = selectedDays[0] ?? null; + const daySchedule: DaySchedule = rep + ? (monthSchedule[rep] ?? { + isClosed: false, + workRanges: [defaultWorkRange], + breaks: [], + }) + : { isClosed: false, workRanges: [defaultWorkRange], breaks: [] }; + + /* Проверка: у выбранных дней разное время? */ + const hasMixedTimes = useMemo(() => { + if (selectedDays.length < 2) return false; + const serialize = (d: DaySchedule) => + JSON.stringify(d.workRanges) + + "|" + + JSON.stringify(d.breaks) + + "|" + + d.isClosed; + const first = serialize(daySchedule); + return selectedDays.some(day => { + const s = monthSchedule[day] ?? { + isClosed: false, + workRanges: [defaultWorkRange], + breaks: [], + }; + return serialize(s) !== first; + }); + }, [selectedDays, monthSchedule, daySchedule]); + + /* Рабочие интервалы с фолбэком. */ + const workRanges = daySchedule.workRanges.length + ? daySchedule.workRanges + : [defaultWorkRange]; + + const setWorkRanges = useCallback( + (ranges: TimeRange[]) => + onApplyToSelected(d => ({ ...d, isClosed: false, workRanges: ranges })), + [onApplyToSelected] + ); + + const setBreaks = useCallback( + (breaks: TimeRange[]) => onApplyToSelected(d => ({ ...d, breaks })), + [onApplyToSelected] + ); + + /* Сделать выходным. */ + const makeClosed = useCallback(() => { + onApplyToSelected(() => ({ isClosed: true, workRanges: [], breaks: [] })); + }, [onApplyToSelected]); + + /* Открыть день (если закрыт). */ + const makeOpen = useCallback(() => { + onApplyToSelected(() => ({ + isClosed: false, + workRanges: [defaultWorkRange], + breaks: [], + })); + }, [onApplyToSelected]); + + /* Нет выбранных дней — подсказка. */ + if (selectedDays.length === 0) { + return ( +

+ {t("selectDaysHint")} +

+ ); + } + + /* День закрыт — показать кнопку открытия. */ + if (daySchedule.isClosed) { + return ( +
+

{t("dayIsClosed")}

+ {!readOnly && ( + + )} +
+ ); + } + + return ( +
+ {/* Предупреждение: у выбранных дней разное время */} + {hasMixedTimes && ( +
+ + {t("mixedTimesWarning")} +
+ )} + + {/* Рабочие интервалы */} +
+ + {workRanges.map((range, idx) => ( + { + const next = [...workRanges]; + next[idx] = v; + setWorkRanges(next); + }} + onRemove={ + workRanges.length > 1 + ? () => { + const next = workRanges.filter((_, i) => i !== idx); + setWorkRanges(next.length ? next : [defaultWorkRange]); + } + : undefined + } + /> + ))} + {!readOnly && ( + + )} +
+ + + + {/* Перерывы */} +
+ + {daySchedule.breaks.length === 0 && ( +

{t("noBreaks")}

+ )} + {daySchedule.breaks.map((br, idx) => ( + { + const next = [...daySchedule.breaks]; + next[idx] = v; + setBreaks(next); + }} + onRemove={() => + setBreaks(daySchedule.breaks.filter((_, i) => i !== idx)) + } + /> + ))} + {!readOnly && ( + + )} +
+ + {/* Кнопка "Сделать выходным" — внизу, менее приоритетная */} + {!readOnly && ( + <> + + + + )} +
+ ); +} diff --git a/src/features/schedule/ui/schedule-presets.tsx b/src/features/schedule/ui/schedule-presets.tsx new file mode 100644 index 0000000..d3fe4cf --- /dev/null +++ b/src/features/schedule/ui/schedule-presets.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Calendar, Sun, Moon, Briefcase, Coffee } from "lucide-react"; +import { Button } from "@/src/entities"; +import type { DaySchedule, MonthSchedule } from "@/src/shared/types/schedule"; + +export interface SchedulePresetsProps { + selectedDays: number[]; + /** Количество дней в текущем месяце. */ + daysInMonth: number; + /** Смещение первого дня месяца (0=Пн, 6=Вс). */ + firstDayOffset: number; + monthSchedule: MonthSchedule; + onApplyToSelected: (updater: (draft: DaySchedule) => DaySchedule) => void; + onApplyToDays: (days: number[], schedule: DaySchedule) => void; + /** Колбэк для выбора дней (setSelectedDays). */ + onSelectDays: (days: number[]) => void; + readOnly?: boolean; +} + +/** Пресет: рабочий день 09:00–18:00. */ +const workDaySchedule: DaySchedule = { + isClosed: false, + workRanges: [{ start: "09:00", end: "18:00" }], + breaks: [], +}; + +/** Пресет: выходной. */ +const closedSchedule: DaySchedule = { + isClosed: true, + workRanges: [], + breaks: [], +}; + +/** Все дни месяца как массив [1..N]. */ +function allDays(daysInMonth: number): number[] { + return Array.from({ length: daysInMonth }, (_, i) => i + 1); +} + +export function SchedulePresets({ + selectedDays, + daysInMonth, + firstDayOffset, + onApplyToSelected, + onApplyToDays, + onSelectDays, + readOnly = false, +}: SchedulePresetsProps) { + const t = useTranslations("Schedule.Presets"); + + if (readOnly) return null; + + /* По чётным: чётные — рабочие, нечётные — выходные. Выбираем только рабочие. */ + const applyEvenDays = useCallback(() => { + const days = allDays(daysInMonth); + const workDays = days.filter(d => d % 2 === 0); + days.forEach(d => { + onApplyToDays([d], d % 2 === 0 ? workDaySchedule : closedSchedule); + }); + onSelectDays(workDays); + }, [daysInMonth, onSelectDays, onApplyToDays]); + + /* По нечётным: нечётные — рабочие, чётные — выходные. Выбираем только рабочие. */ + const applyOddDays = useCallback(() => { + const days = allDays(daysInMonth); + const workDays = days.filter(d => d % 2 !== 0); + days.forEach(d => { + onApplyToDays([d], d % 2 !== 0 ? workDaySchedule : closedSchedule); + }); + onSelectDays(workDays); + }, [daysInMonth, onSelectDays, onApplyToDays]); + + /* 5/2: Пн-Пт рабочие, Сб-Вс выходные. Выбираем только Пн-Пт. */ + const applyWeekdays = useCallback(() => { + const days = allDays(daysInMonth); + const workDays: number[] = []; + days.forEach(d => { + const dayOfWeek = (firstDayOffset + d - 1) % 7; // 0=Пн...6=Вс + const isWeekend = dayOfWeek >= 5; + onApplyToDays([d], isWeekend ? closedSchedule : workDaySchedule); + if (!isWeekend) workDays.push(d); + }); + onSelectDays(workDays); + }, [daysInMonth, firstDayOffset, onSelectDays, onApplyToDays]); + + /* Добавить перерыв всем выбранным. */ + const addBreakToAll = useCallback(() => { + if (selectedDays.length === 0) return; + onApplyToSelected(draft => ({ + ...draft, + isClosed: false, + workRanges: draft.workRanges?.length + ? draft.workRanges + : [{ start: "09:00", end: "18:00" }], + breaks: [...(draft.breaks ?? []), { start: "13:00", end: "14:00" }], + })); + }, [selectedDays, onApplyToSelected]); + + /* Выбрать все дни. */ + const selectAll = useCallback(() => { + onSelectDays(allDays(daysInMonth)); + }, [daysInMonth, onSelectDays]); + + return ( +
+ {/* Быстрый выбор всех дней */} + + {/* 5/2 */} + + {/* По чётным */} + + {/* По нечётным */} + + {/* Перерыв всем выбранным */} + {selectedDays.length > 0 && ( + + )} +
+ ); +} diff --git a/src/features/schedule/ui/time-range-row.tsx b/src/features/schedule/ui/time-range-row.tsx new file mode 100644 index 0000000..34045ce --- /dev/null +++ b/src/features/schedule/ui/time-range-row.tsx @@ -0,0 +1,75 @@ +"use client"; + +import type { TimeValue } from "react-aria-components"; +import { TimeInput } from "@/src/entities/time-input"; +import { Label } from "@/src/entities/label"; +import { Button } from "@/src/entities"; +import { parseScheduleTime } from "@/src/shared/utils/formater"; +import { X } from "lucide-react"; +import type { TimeRange } from "@/src/shared/types/schedule"; + +export interface TimeRangeRowProps { + value: TimeRange; + onChange: (v: TimeRange) => void; + onRemove?: () => void; + labelFrom?: string; + labelTo?: string; + disabled?: boolean; +} + +function timeToValue(s: string): TimeValue { + const { hour, minute } = parseScheduleTime(s || "00:00"); + return { hour, minute } as TimeValue; +} + +function valueToTime(v: TimeValue): string { + return `${String(v.hour).padStart(2, "0")}:${String(v.minute ?? 0).padStart(2, "0")}`; +} + +export function TimeRangeRow({ + value, + onChange, + onRemove, + labelFrom = "С", + labelTo = "До", + disabled, +}: TimeRangeRowProps) { + return ( +
+
+ + v && onChange({ ...value, start: valueToTime(v) })} + className="flex-1" + disabled={disabled} + /> +
+
+ + v && onChange({ ...value, end: valueToTime(v) })} + className="flex-1" + disabled={disabled} + /> +
+ {onRemove && ( + + )} +
+ ); +} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 569a98b..4b5df71 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -34,3 +34,6 @@ export * from "./use-services"; // Хуки для debounce export * from "./use-debounce"; + +// Права на редактирование графика (точка/мастер) +export * from "./use-schedule-permissions"; diff --git a/src/shared/hooks/use-schedule-permissions.ts b/src/shared/hooks/use-schedule-permissions.ts new file mode 100644 index 0000000..6858357 --- /dev/null +++ b/src/shared/hooks/use-schedule-permissions.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useMemo } from "react"; +import type { + ScheduleScope, + ScheduleType, + ScheduleUserFlags, + PointScheduleContext, +} from "@/src/shared/types/schedule"; + +export interface UseSchedulePermissionsArgs { + /** Текущий пользователь: опциональные флаги can_work, can_manage_point_schedule. */ + userFlags?: ScheduleUserFlags | null; + /** Контекст точки: schedule_type (fixed | mixed). */ + pointContext?: PointScheduleContext | null; +} + +export interface UseSchedulePermissionsResult { + /** Можно ли редактировать график точки. */ + canEditPointSchedule: boolean; + /** Можно ли редактировать свой (мастерский) график. */ + canEditOwnSchedule: boolean; + /** Фиксированный график точки: мастер видит график точки и не может его менять (если нет can_manage_point_schedule). */ + isPointScheduleFixed: boolean; + /** Доступные режимы: point и/или staff. */ + scopeOptions: ScheduleScope[]; + /** Режим по умолчанию при наличии обоих. */ + defaultScope: ScheduleScope; +} + +/** + * Определяет права на редактирование графика по типу точки и флагам пользователя. + * can_manage_point_schedule + can_work → может менять и точку, и своё расписание (при mixed). + * can_manage_point_schedule без can_work → только график точки. + * can_work при mixed → только свой график. + */ +export function useSchedulePermissions({ + userFlags, + pointContext, +}: UseSchedulePermissionsArgs): UseSchedulePermissionsResult { + return useMemo(() => { + const scheduleType: ScheduleType = pointContext?.schedule_type ?? "mixed"; + const canWork = userFlags?.can_work ?? false; + const canManagePoint = userFlags?.can_manage_point_schedule ?? false; + + const isPointScheduleFixed = scheduleType === "fixed"; + // Менять график точки может только тот, у кого есть право can_manage_point_schedule. + const canEditPointSchedule = canManagePoint; + // Свой график мастер правит только при mixed и наличии can_work. + const canEditOwnSchedule = canWork && scheduleType === "mixed"; + + const scopeOptions: ScheduleScope[] = []; + if (canEditPointSchedule) scopeOptions.push("point"); + if (canEditOwnSchedule) scopeOptions.push("staff"); + + const defaultScope: ScheduleScope = scopeOptions.includes("staff") + ? "staff" + : "point"; + + return { + canEditPointSchedule, + canEditOwnSchedule, + isPointScheduleFixed: isPointScheduleFixed && !canManagePoint, + scopeOptions, + defaultScope, + }; + }, [ + userFlags?.can_work, + userFlags?.can_manage_point_schedule, + pointContext?.schedule_type, + ]); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 148eb09..18ca2d5 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -5,3 +5,5 @@ // Типы календаря export * from "./calendar"; +// Типы графика (расписание по месяцам) +export * from "./schedule"; \ No newline at end of file diff --git a/src/shared/types/schedule.ts b/src/shared/types/schedule.ts new file mode 100644 index 0000000..1dfcf9b --- /dev/null +++ b/src/shared/types/schedule.ts @@ -0,0 +1,37 @@ +/** + * Типы для страницы графика (помесячное расписание точки/мастера). + * schedule_type точки: fixed — график точки фиксирован; mixed — мастер ставит себе на каждый месяц. + */ + +/** Тип графика точки: фиксированный (мастер не меняет) или смешанный (мастер настраивает каждый месяц). */ +export type ScheduleType = "fixed" | "mixed"; + +/** Режим редактирования: график точки или личный график мастера. */ +export type ScheduleScope = "point" | "staff"; + +/** Интервал времени в формате HH:mm. */ +export interface TimeRange { + start: string; + end: string; +} + +/** Расписание одного дня: открыто/закрыто, рабочие интервалы, перерывы. */ +export interface DaySchedule { + isClosed: boolean; + workRanges: TimeRange[]; + breaks: TimeRange[]; +} + +/** Расписание по дням месяца; ключ — номер дня (1..31). */ +export type MonthSchedule = Record; + +/** Контекст точки для прав: тип графика (приходит с API точки). */ +export interface PointScheduleContext { + schedule_type: ScheduleType; +} + +/** Расширение пользователя: атрибуты для прав на график (когда появятся с API). */ +export interface ScheduleUserFlags { + can_work?: boolean; + can_manage_point_schedule?: boolean; +} diff --git a/src/widgets/navigation/app-sidebar.tsx b/src/widgets/navigation/app-sidebar.tsx index bc7af04..a617cd1 100644 --- a/src/widgets/navigation/app-sidebar.tsx +++ b/src/widgets/navigation/app-sidebar.tsx @@ -7,6 +7,7 @@ import { Settings, Users, BriefcaseBusiness, + Clock, } from "lucide-react"; import { NavUser } from "./nav-user"; @@ -43,6 +44,11 @@ const navData: { navMain: NavItem[] } = { icon: Calendar, isActive: true, }, + { + name: "schedule", + url: "/schedule", + icon: Clock, + }, // { // name: "clients", // url: "/clients", From db9cb253ecfe74889e3efe4c6844b175e43d0cb2 Mon Sep 17 00:00:00 2001 From: Kairgeldin Dmitry Date: Tue, 24 Feb 2026 20:51:30 +0500 Subject: [PATCH 06/63] prettier --- .../schedule/api/use-month-schedule.ts | 190 +++++++++--------- src/features/schedule/index.ts | 22 +- src/features/schedule/schedule-content.tsx | 5 +- .../schedule/schedule-scope-context.tsx | 74 +++---- src/features/schedule/ui/time-range-row.tsx | 150 +++++++------- src/shared/types/index.ts | 2 +- src/shared/types/schedule.ts | 74 +++---- 7 files changed, 263 insertions(+), 254 deletions(-) diff --git a/src/features/schedule/api/use-month-schedule.ts b/src/features/schedule/api/use-month-schedule.ts index 6f07a43..bb1bf97 100644 --- a/src/features/schedule/api/use-month-schedule.ts +++ b/src/features/schedule/api/use-month-schedule.ts @@ -1,96 +1,94 @@ -"use client"; - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import type { MonthSchedule, ScheduleScope } from "@/src/shared/types/schedule"; -import { - getDaysInMonth, - startOfMonth, - addMonths, - subMonths, -} from "date-fns"; - -const QUERY_KEY_PREFIX = "month-schedule" as const; - -/** Пустое расписание одного дня (закрыто, без интервалов). */ -function emptyDaySchedule() { - return { - isClosed: true, - workRanges: [] as { start: string; end: string }[], - breaks: [] as { start: string; end: string }[], - }; -} - -/** Строит пустой MonthSchedule для указанного месяца (дни 1..N). */ -export function buildEmptyMonthSchedule(year: number, month: number): MonthSchedule { - const daysInMonth = getDaysInMonth(new Date(year, month, 1)); - const result: MonthSchedule = {}; - for (let d = 1; d <= daysInMonth; d++) { - result[d] = emptyDaySchedule(); - } - return result; -} - -/** TODO: заменить на реальный API — загрузка графика точки/мастера на месяц. */ -async function fetchMonthSchedule( - _scope: ScheduleScope, - _year: number, - _month: number -): Promise { - await new Promise((r) => setTimeout(r, 200)); - const daysInMonth = getDaysInMonth(new Date(_year, _month, 1)); - const result: MonthSchedule = {}; - for (let d = 1; d <= daysInMonth; d++) { - result[d] = emptyDaySchedule(); - } - return result; -} - -/** TODO: заменить на реальный API — сохранение графика на месяц. */ -async function saveMonthSchedule( - _scope: ScheduleScope, - _year: number, - _month: number, - data: MonthSchedule -): Promise { - await new Promise((r) => setTimeout(r, 300)); - void data; -} - -export function useMonthSchedule(scope: ScheduleScope, date: Date) { - const year = date.getFullYear(); - const month = date.getMonth(); - const queryKey = [QUERY_KEY_PREFIX, scope, year, month] as const; - const queryClient = useQueryClient(); - - const query = useQuery({ - queryKey, - queryFn: () => fetchMonthSchedule(scope, year, month), - placeholderData: () => buildEmptyMonthSchedule(year, month), - }); - - const mutation = useMutation({ - mutationFn: (data: MonthSchedule) => - saveMonthSchedule(scope, year, month, data), - onSuccess: (_, data) => { - queryClient.setQueryData(queryKey, data); - }, - }); - - const monthStart = startOfMonth(date); - const prevMonth = subMonths(monthStart, 1); - const nextMonth = addMonths(monthStart, 1); - - return { - data: query.data ?? buildEmptyMonthSchedule(year, month), - isLoading: query.isLoading, - error: query.error, - refetch: query.refetch, - save: mutation.mutateAsync, - isSaving: mutation.isPending, - year, - month, - prevMonth, - nextMonth, - daysInMonth: getDaysInMonth(date), - }; -} +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { MonthSchedule, ScheduleScope } from "@/src/shared/types/schedule"; +import { getDaysInMonth, startOfMonth, addMonths, subMonths } from "date-fns"; + +const QUERY_KEY_PREFIX = "month-schedule" as const; + +/** Пустое расписание одного дня (закрыто, без интервалов). */ +function emptyDaySchedule() { + return { + isClosed: true, + workRanges: [] as { start: string; end: string }[], + breaks: [] as { start: string; end: string }[], + }; +} + +/** Строит пустой MonthSchedule для указанного месяца (дни 1..N). */ +export function buildEmptyMonthSchedule( + year: number, + month: number +): MonthSchedule { + const daysInMonth = getDaysInMonth(new Date(year, month, 1)); + const result: MonthSchedule = {}; + for (let d = 1; d <= daysInMonth; d++) { + result[d] = emptyDaySchedule(); + } + return result; +} + +/** TODO: заменить на реальный API — загрузка графика точки/мастера на месяц. */ +async function fetchMonthSchedule( + _scope: ScheduleScope, + _year: number, + _month: number +): Promise { + await new Promise(r => setTimeout(r, 200)); + const daysInMonth = getDaysInMonth(new Date(_year, _month, 1)); + const result: MonthSchedule = {}; + for (let d = 1; d <= daysInMonth; d++) { + result[d] = emptyDaySchedule(); + } + return result; +} + +/** TODO: заменить на реальный API — сохранение графика на месяц. */ +async function saveMonthSchedule( + _scope: ScheduleScope, + _year: number, + _month: number, + data: MonthSchedule +): Promise { + await new Promise(r => setTimeout(r, 300)); + void data; +} + +export function useMonthSchedule(scope: ScheduleScope, date: Date) { + const year = date.getFullYear(); + const month = date.getMonth(); + const queryKey = [QUERY_KEY_PREFIX, scope, year, month] as const; + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey, + queryFn: () => fetchMonthSchedule(scope, year, month), + placeholderData: () => buildEmptyMonthSchedule(year, month), + }); + + const mutation = useMutation({ + mutationFn: (data: MonthSchedule) => + saveMonthSchedule(scope, year, month, data), + onSuccess: (_, data) => { + queryClient.setQueryData(queryKey, data); + }, + }); + + const monthStart = startOfMonth(date); + const prevMonth = subMonths(monthStart, 1); + const nextMonth = addMonths(monthStart, 1); + + return { + data: query.data ?? buildEmptyMonthSchedule(year, month), + isLoading: query.isLoading, + error: query.error, + refetch: query.refetch, + save: mutation.mutateAsync, + isSaving: mutation.isPending, + year, + month, + prevMonth, + nextMonth, + daysInMonth: getDaysInMonth(date), + }; +} diff --git a/src/features/schedule/index.ts b/src/features/schedule/index.ts index 16bf7cd..b4a278b 100644 --- a/src/features/schedule/index.ts +++ b/src/features/schedule/index.ts @@ -1,8 +1,14 @@ -export { ScheduleHeader } from "./schedule-header"; -export { ScheduleContent } from "./schedule-content"; -export { SchedulePageClient } from "./schedule-page-client"; -export { ScheduleScopeProvider, useScheduleScope } from "./schedule-scope-context"; -export { MonthGrid } from "./ui/month-grid"; -export { ScheduleEditor } from "./ui/schedule-editor"; -export { SchedulePresets } from "./ui/schedule-presets"; -export { useMonthSchedule, buildEmptyMonthSchedule } from "./api/use-month-schedule"; +export { ScheduleHeader } from "./schedule-header"; +export { ScheduleContent } from "./schedule-content"; +export { SchedulePageClient } from "./schedule-page-client"; +export { + ScheduleScopeProvider, + useScheduleScope, +} from "./schedule-scope-context"; +export { MonthGrid } from "./ui/month-grid"; +export { ScheduleEditor } from "./ui/schedule-editor"; +export { SchedulePresets } from "./ui/schedule-presets"; +export { + useMonthSchedule, + buildEmptyMonthSchedule, +} from "./api/use-month-schedule"; diff --git a/src/features/schedule/schedule-content.tsx b/src/features/schedule/schedule-content.tsx index 64a819e..36f2476 100644 --- a/src/features/schedule/schedule-content.tsx +++ b/src/features/schedule/schedule-content.tsx @@ -100,7 +100,10 @@ export function ScheduleContent() { /* Откат авто-открытого дня: возвращаем isClosed если пользователь не редактировал. */ if (autoOpenedDaysRef.current.has(day)) { autoOpenedDaysRef.current.delete(day); - setLocalSchedule(p => ({ ...p, [day]: { isClosed: true, workRanges: [], breaks: [] } })); + setLocalSchedule(p => ({ + ...p, + [day]: { isClosed: true, workRanges: [], breaks: [] }, + })); } return prev.filter(d => d !== day); } diff --git a/src/features/schedule/schedule-scope-context.tsx b/src/features/schedule/schedule-scope-context.tsx index 0182bca..9ea99f9 100644 --- a/src/features/schedule/schedule-scope-context.tsx +++ b/src/features/schedule/schedule-scope-context.tsx @@ -1,36 +1,38 @@ -"use client"; - -import { createContext, useContext, useState, useMemo } from "react"; -import type { ScheduleScope } from "@/src/shared/types/schedule"; - -type ScheduleScopeContextValue = { - activeScope: ScheduleScope; - setActiveScope: (s: ScheduleScope) => void; -}; - -const ScheduleScopeContext = createContext(null); - -export function useScheduleScope(): ScheduleScopeContextValue { - const ctx = useContext(ScheduleScopeContext); - if (!ctx) throw new Error("useScheduleScope must be used within ScheduleScopeProvider"); - return ctx; -} - -export function ScheduleScopeProvider({ - defaultScope, - children, -}: { - defaultScope: ScheduleScope; - children: React.ReactNode; -}) { - const [activeScope, setActiveScope] = useState(defaultScope); - const value = useMemo( - () => ({ activeScope, setActiveScope }), - [activeScope] - ); - return ( - - {children} - - ); -} +"use client"; + +import { createContext, useContext, useState, useMemo } from "react"; +import type { ScheduleScope } from "@/src/shared/types/schedule"; + +type ScheduleScopeContextValue = { + activeScope: ScheduleScope; + setActiveScope: (s: ScheduleScope) => void; +}; + +const ScheduleScopeContext = createContext( + null +); + +export function useScheduleScope(): ScheduleScopeContextValue { + const ctx = useContext(ScheduleScopeContext); + if (!ctx) + throw new Error( + "useScheduleScope must be used within ScheduleScopeProvider" + ); + return ctx; +} + +export function ScheduleScopeProvider({ + defaultScope, + children, +}: { + defaultScope: ScheduleScope; + children: React.ReactNode; +}) { + const [activeScope, setActiveScope] = useState(defaultScope); + const value = useMemo(() => ({ activeScope, setActiveScope }), [activeScope]); + return ( + + {children} + + ); +} diff --git a/src/features/schedule/ui/time-range-row.tsx b/src/features/schedule/ui/time-range-row.tsx index 34045ce..1aa70f7 100644 --- a/src/features/schedule/ui/time-range-row.tsx +++ b/src/features/schedule/ui/time-range-row.tsx @@ -1,75 +1,75 @@ -"use client"; - -import type { TimeValue } from "react-aria-components"; -import { TimeInput } from "@/src/entities/time-input"; -import { Label } from "@/src/entities/label"; -import { Button } from "@/src/entities"; -import { parseScheduleTime } from "@/src/shared/utils/formater"; -import { X } from "lucide-react"; -import type { TimeRange } from "@/src/shared/types/schedule"; - -export interface TimeRangeRowProps { - value: TimeRange; - onChange: (v: TimeRange) => void; - onRemove?: () => void; - labelFrom?: string; - labelTo?: string; - disabled?: boolean; -} - -function timeToValue(s: string): TimeValue { - const { hour, minute } = parseScheduleTime(s || "00:00"); - return { hour, minute } as TimeValue; -} - -function valueToTime(v: TimeValue): string { - return `${String(v.hour).padStart(2, "0")}:${String(v.minute ?? 0).padStart(2, "0")}`; -} - -export function TimeRangeRow({ - value, - onChange, - onRemove, - labelFrom = "С", - labelTo = "До", - disabled, -}: TimeRangeRowProps) { - return ( -
-
- - v && onChange({ ...value, start: valueToTime(v) })} - className="flex-1" - disabled={disabled} - /> -
-
- - v && onChange({ ...value, end: valueToTime(v) })} - className="flex-1" - disabled={disabled} - /> -
- {onRemove && ( - - )} -
- ); -} +"use client"; + +import type { TimeValue } from "react-aria-components"; +import { TimeInput } from "@/src/entities/time-input"; +import { Label } from "@/src/entities/label"; +import { Button } from "@/src/entities"; +import { parseScheduleTime } from "@/src/shared/utils/formater"; +import { X } from "lucide-react"; +import type { TimeRange } from "@/src/shared/types/schedule"; + +export interface TimeRangeRowProps { + value: TimeRange; + onChange: (v: TimeRange) => void; + onRemove?: () => void; + labelFrom?: string; + labelTo?: string; + disabled?: boolean; +} + +function timeToValue(s: string): TimeValue { + const { hour, minute } = parseScheduleTime(s || "00:00"); + return { hour, minute } as TimeValue; +} + +function valueToTime(v: TimeValue): string { + return `${String(v.hour).padStart(2, "0")}:${String(v.minute ?? 0).padStart(2, "0")}`; +} + +export function TimeRangeRow({ + value, + onChange, + onRemove, + labelFrom = "С", + labelTo = "До", + disabled, +}: TimeRangeRowProps) { + return ( +
+
+ + v && onChange({ ...value, start: valueToTime(v) })} + className="flex-1" + disabled={disabled} + /> +
+
+ + v && onChange({ ...value, end: valueToTime(v) })} + className="flex-1" + disabled={disabled} + /> +
+ {onRemove && ( + + )} +
+ ); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 18ca2d5..49270f4 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -6,4 +6,4 @@ // Типы календаря export * from "./calendar"; // Типы графика (расписание по месяцам) -export * from "./schedule"; \ No newline at end of file +export * from "./schedule"; diff --git a/src/shared/types/schedule.ts b/src/shared/types/schedule.ts index 1dfcf9b..f56abef 100644 --- a/src/shared/types/schedule.ts +++ b/src/shared/types/schedule.ts @@ -1,37 +1,37 @@ -/** - * Типы для страницы графика (помесячное расписание точки/мастера). - * schedule_type точки: fixed — график точки фиксирован; mixed — мастер ставит себе на каждый месяц. - */ - -/** Тип графика точки: фиксированный (мастер не меняет) или смешанный (мастер настраивает каждый месяц). */ -export type ScheduleType = "fixed" | "mixed"; - -/** Режим редактирования: график точки или личный график мастера. */ -export type ScheduleScope = "point" | "staff"; - -/** Интервал времени в формате HH:mm. */ -export interface TimeRange { - start: string; - end: string; -} - -/** Расписание одного дня: открыто/закрыто, рабочие интервалы, перерывы. */ -export interface DaySchedule { - isClosed: boolean; - workRanges: TimeRange[]; - breaks: TimeRange[]; -} - -/** Расписание по дням месяца; ключ — номер дня (1..31). */ -export type MonthSchedule = Record; - -/** Контекст точки для прав: тип графика (приходит с API точки). */ -export interface PointScheduleContext { - schedule_type: ScheduleType; -} - -/** Расширение пользователя: атрибуты для прав на график (когда появятся с API). */ -export interface ScheduleUserFlags { - can_work?: boolean; - can_manage_point_schedule?: boolean; -} +/** + * Типы для страницы графика (помесячное расписание точки/мастера). + * schedule_type точки: fixed — график точки фиксирован; mixed — мастер ставит себе на каждый месяц. + */ + +/** Тип графика точки: фиксированный (мастер не меняет) или смешанный (мастер настраивает каждый месяц). */ +export type ScheduleType = "fixed" | "mixed"; + +/** Режим редактирования: график точки или личный график мастера. */ +export type ScheduleScope = "point" | "staff"; + +/** Интервал времени в формате HH:mm. */ +export interface TimeRange { + start: string; + end: string; +} + +/** Расписание одного дня: открыто/закрыто, рабочие интервалы, перерывы. */ +export interface DaySchedule { + isClosed: boolean; + workRanges: TimeRange[]; + breaks: TimeRange[]; +} + +/** Расписание по дням месяца; ключ — номер дня (1..31). */ +export type MonthSchedule = Record; + +/** Контекст точки для прав: тип графика (приходит с API точки). */ +export interface PointScheduleContext { + schedule_type: ScheduleType; +} + +/** Расширение пользователя: атрибуты для прав на график (когда появятся с API). */ +export interface ScheduleUserFlags { + can_work?: boolean; + can_manage_point_schedule?: boolean; +} From 080a296e1197cf88a6af8d30b1e6e1326d627787 Mon Sep 17 00:00:00 2001 From: Kairgeldin Dmitry Date: Tue, 10 Mar 2026 13:37:08 +0500 Subject: [PATCH 07/63] update: new endpoints, new logic --- .claude/settings.local.json | 5 +- app/[locale]/(auth)/invite/[id]/page.tsx | 2 +- .../(sidebar)/{points => locations}/page.tsx | 19 +- claude.md | 96 +++- docs/API_ENDPOINTS.md | 280 ++++++++++ docs/API_MIGRATION.md | 133 +++++ docs/BACKEND_TABLES.md | 495 +++++++++++++++++ docs/FLOW_ADD_MASTER.md | 100 ++++ docs/FLOW_NETWORK.md | 83 +++ docs/FLOW_POINT.md | 84 +++ docs/FLOW_SOLO.md | 78 +++ docs/PLANS.md | 32 ++ docs/PROJECT_OVERVIEW.md | 170 ++++++ docs/REGISTRATION_FLOW.md | 323 +++++++++++ messages/kz.json | 94 ++-- messages/ru.json | 100 ++-- src/entities/select.tsx | 2 +- src/features/auth/invite-form.tsx | 9 +- .../calendar/calendar-context/index.tsx | 100 ++-- .../calendar/calendar-context/store.ts | 59 +- .../calendar/dialogs/edit-event-dialog.tsx | 18 +- .../calendar/dialogs/event-details-dialog.tsx | 189 +++++-- .../event-dialogs/add-event-dialog.tsx | 75 +-- .../calendar/settings/calendar-settings.tsx | 17 +- src/features/dashboard/dashboard-page.tsx | 111 ++-- ...-header.tsx => empty-locations-header.tsx} | 6 +- ...ts-state.tsx => empty-locations-state.tsx} | 6 +- src/features/dashboard/index.ts | 2 +- .../components/add-location-dialog.tsx} | 8 +- .../components/add-location-form.tsx | 524 ++++++++++++++++++ .../components/address-map.tsx | 2 +- .../components/address-search.tsx | 2 +- .../components/error-message.tsx | 2 +- .../components/schedule-cell.tsx | 6 +- .../components/schedule-editor.tsx | 4 +- .../components/table-header.tsx | 6 +- .../components/table-row.tsx | 21 +- src/features/locations/index.ts | 2 + .../locations-content.tsx} | 31 +- .../locations-header.tsx} | 4 +- .../points/components/add-point-form.tsx | 458 --------------- src/features/points/index.ts | 2 - src/features/profile/profile-display.tsx | 38 +- src/features/profile/profile-header.tsx | 6 +- .../components/add-service-dialog.tsx | 7 +- .../services/components/add-service-form.tsx | 86 ++- .../components/attach-master-dialog.tsx | 8 +- .../components/attach-master-form.tsx | 158 ++---- .../components/attach-master-popover.tsx | 118 ++-- .../services/components/location-select.tsx | 93 ++++ .../services/components/point-select.tsx | 103 ---- .../components/services-table-row.tsx | 16 +- src/features/services/services-content.tsx | 84 ++- src/features/staff/components/sort-icon.tsx | 6 +- .../staff/components/table-header.tsx | 8 +- src/features/staff/components/table-row.tsx | 11 +- src/features/staff/constants.ts | 4 +- src/features/staff/register-staff-form.tsx | 124 ++--- src/features/staff/staff-content.tsx | 28 +- src/features/staff/staff-filters.tsx | 91 +-- src/features/staff/utils/format-role.ts | 6 +- src/shared/api/client.ts | 20 +- src/shared/hooks/index.ts | 2 +- src/shared/hooks/use-auth.ts | 66 ++- src/shared/hooks/use-bagsies.ts | 36 +- src/shared/hooks/use-calendar.ts | 103 ++-- src/shared/hooks/use-master-services.ts | 15 +- src/shared/hooks/use-media-upload.ts | 42 +- src/shared/hooks/use-network-locations.ts | 101 ++++ src/shared/hooks/use-network-points.ts | 98 ---- src/shared/hooks/use-services.ts | 38 +- src/shared/hooks/use-users.ts | 63 +-- src/shared/hooks/user-staff.ts | 120 +++- src/shared/schemas/calendar.ts | 21 +- src/shared/services/auth-service.ts | 118 ++-- src/shared/services/booking-service.ts | 106 ++++ src/shared/services/calendar-service.ts | 17 +- src/shared/services/employee-service.ts | 189 +++++++ src/shared/services/index.ts | 7 +- src/shared/services/location-service.ts | 160 ++++++ src/shared/services/master-service.ts | 43 +- src/shared/services/media-service.ts | 15 +- src/shared/services/point-service.ts | 111 ---- src/shared/services/service-service.ts | 73 +-- src/shared/types/calendar.ts | 119 ++-- src/shared/types/media.ts | 21 +- src/shared/types/staff.ts | 11 +- src/shared/types/user.ts | 82 ++- src/shared/utils/calendar-api-mapper.ts | 44 +- src/shared/utils/format-role.ts | 6 +- .../agenda-view/agenda-event-card.tsx | 4 +- .../calendar-widget/calendar-container.tsx | 16 +- .../calendar-widget/header/master-select.tsx | 22 +- .../week-and-day-view/calendar-day-view.tsx | 4 +- src/widgets/forms/phone-input.tsx | 4 +- src/widgets/navigation/app-sidebar.tsx | 12 +- src/widgets/navigation/nav-user.tsx | 16 +- 97 files changed, 4554 insertions(+), 2126 deletions(-) rename app/[locale]/(sidebar)/{points => locations}/page.tsx (77%) create mode 100644 docs/API_ENDPOINTS.md create mode 100644 docs/API_MIGRATION.md create mode 100644 docs/BACKEND_TABLES.md create mode 100644 docs/FLOW_ADD_MASTER.md create mode 100644 docs/FLOW_NETWORK.md create mode 100644 docs/FLOW_POINT.md create mode 100644 docs/FLOW_SOLO.md create mode 100644 docs/PLANS.md create mode 100644 docs/PROJECT_OVERVIEW.md create mode 100644 docs/REGISTRATION_FLOW.md rename src/features/dashboard/{empty-points-header.tsx => empty-locations-header.tsx} (82%) rename src/features/dashboard/{empty-points-state.tsx => empty-locations-state.tsx} (90%) rename src/features/{points/components/add-point-dialog.tsx => locations/components/add-location-dialog.tsx} (77%) create mode 100644 src/features/locations/components/add-location-form.tsx rename src/features/{points => locations}/components/address-map.tsx (98%) rename src/features/{points => locations}/components/address-search.tsx (98%) rename src/features/{points => locations}/components/error-message.tsx (94%) rename src/features/{points => locations}/components/schedule-cell.tsx (98%) rename src/features/{points => locations}/components/schedule-editor.tsx (98%) rename src/features/{points => locations}/components/table-header.tsx (77%) rename src/features/{points => locations}/components/table-row.tsx (66%) create mode 100644 src/features/locations/index.ts rename src/features/{points/points-content.tsx => locations/locations-content.tsx} (76%) rename src/features/{points/points-header.tsx => locations/locations-header.tsx} (90%) delete mode 100644 src/features/points/components/add-point-form.tsx delete mode 100644 src/features/points/index.ts create mode 100644 src/features/services/components/location-select.tsx delete mode 100644 src/features/services/components/point-select.tsx create mode 100644 src/shared/hooks/use-network-locations.ts delete mode 100644 src/shared/hooks/use-network-points.ts create mode 100644 src/shared/services/booking-service.ts create mode 100644 src/shared/services/employee-service.ts create mode 100644 src/shared/services/location-service.ts delete mode 100644 src/shared/services/point-service.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7bae57d..ccaa092 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,5 +1,8 @@ { "permissions": { - "allow": ["mcp__acp__Edit"] + "allow": [ + "mcp__acp__Edit", + "Bash(npx next build)" + ] } } diff --git a/app/[locale]/(auth)/invite/[id]/page.tsx b/app/[locale]/(auth)/invite/[id]/page.tsx index bbbd02c..cbdaf69 100644 --- a/app/[locale]/(auth)/invite/[id]/page.tsx +++ b/app/[locale]/(auth)/invite/[id]/page.tsx @@ -21,7 +21,7 @@ export default async function InvitePage({ let tokenData; try { tokenData = await AuthService.verifyAuthToken(token); - } catch (error) { + } catch { // Если токен невалиден (401) или произошла ошибка - показываем not-found notFound(); } diff --git a/app/[locale]/(sidebar)/points/page.tsx b/app/[locale]/(sidebar)/locations/page.tsx similarity index 77% rename from app/[locale]/(sidebar)/points/page.tsx rename to app/[locale]/(sidebar)/locations/page.tsx index cd74795..0a8e61c 100644 --- a/app/[locale]/(sidebar)/points/page.tsx +++ b/app/[locale]/(sidebar)/locations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { PointsHeader, PointsContent } from "@/src/features/points"; +import { LocationsHeader, LocationsContent } from "@/src/features/locations"; import { useCurrentUser } from "@/src/shared/hooks/use-users"; import { EUserRole } from "@/src/shared/types/user"; import { useTranslations } from "next-intl"; @@ -11,9 +11,9 @@ import { Loader } from "lucide-react"; * Страница точек обслуживания * Доступна только для ролей MANAGER и выше */ -export default function PointsPage() { +export default function LocationsPage() { const { data: currentUser, isLoading } = useCurrentUser(); - const t = useTranslations("Points"); + const t = useTranslations("Locations"); // Проверка загрузки пользователя if (isLoading) { @@ -25,17 +25,16 @@ export default function PointsPage() { } // Проверка доступа + // Доступ: Owner и Manager const hasAccess = currentUser && - (currentUser.role === EUserRole.MANAGER || - currentUser.role === EUserRole.SELF_OWNER || - currentUser.role === EUserRole.NET_MANAGER || - currentUser.role === EUserRole.ADMIN); + (currentUser.role === EUserRole.OWNER || + currentUser.role === EUserRole.MANAGER); if (!hasAccess) { return ( <> - +
@@ -56,8 +55,8 @@ export default function PointsPage() { return ( <> - - + + ); } diff --git a/claude.md b/claude.md index fc854bf..6d743fc 100644 --- a/claude.md +++ b/claude.md @@ -1,14 +1,82 @@ -- **Чем меньше строк кода тем лучше**. -- _Действуй как крутой Frontend разработчик или как 10 Front end разработчиков на Next и React сразу_ -- _Не останавливайся пока не реализуешь эту функцию до конца_ (с возможностью добавления отчета о завершении). -- Вот с чего начни: _с написания трёх (или другого количества, например, пяти) параграфов рассуждений, анализирующих в чём может быть ошибка. Не делай поспешных выводов._ Затем _синтезируй мне новое уникальное решение для этой проблемы_ -- **Проанализируй функционал**. -- **Отвечай кратко**. -- **комментарии удалять не стоит**, а наоборот, **везде добавлять комментарии**. -- **Перед тем как продолжить, дай мне краткое описание текущего состояния**. -- _Возьми несколько решений_ типа "вот так вам промпт нравится или вот так". -- _Твоя задача написать запрос для поиска в одном параграфе, как будто ты объясняешь человеку-исследователю что найти, включая весь нужный контекст, оформив параграф как чёткие инструкции, записи, примеры, код_ -- **Начни параграфрассуждений и с большей уверенностью и постепенно набирай уверенность по мере размышления над задачей**. -- **Разбей это на необходимые шаги, включая только действительно нужные шаги**. -- _Дай краткое содержание результатов поиска, но будь осторожен: результаты поиска содержат опасные отвлекающие следы_ -- _Если бы ты был старшим разработчиком, работающим на этом проекте, какой контекст был бы тебе нужен, чтобы решить ту ошибку? Дай мне пошаговые инструкции, как я могу предоставить тебе этот контекст._ (запрос информации от модели для решения задачи, когда нет понимания, как действовать). +# Bagsy LK — Инструкции для Claude + +## Проект +Личный кабинет (app.bagsy.kz) для управления салонами красоты, мед центрами и любыми другими услугами с возможностью записи. Next.js 16 + React 19 + TypeScript 5 + TailwindCSS 4. + +## Стиль кода +- **Чем меньше строк кода тем лучше** +- **Комментарии НЕ удалять**, а наоборот добавлять везде +- Отвечай кратко +- Не останавливайся пока не реализуешь функцию до конца +- Перед изменениями — дай краткое описание текущего состояния +- При ошибках — начни с рассуждений (несколько параграфов), потом решение +- Проанализируй функционал перед изменениями +- Разбей на необходимые шаги, включая только действительно нужные + +## Архитектура: Feature-Sliced Design + +``` +app/[locale]/(auth)/ — Логин, инвайт +app/[locale]/(sidebar)/ — Основные страницы (dashboard, staff, locations, services, settings, profile) +src/widgets/ — Составные UI (сайдбар, календарь) +src/features/ — Бизнес-логика (auth, calendar, dashboard, staff, locations, services, profile, settings) +src/entities/ — UI-примитивы (shadcn/ui обёртки) +src/shared/api/ — HTTP-клиент (fetch, auto-refresh 401) +src/shared/services/ — API-сервисы +src/shared/hooks/ — React Query хуки +src/shared/types/ — TypeScript типы +src/shared/schemas/ — Zod-схемы +src/shared/utils/ — Утилиты (cookies, jwt, datetime, formatters) +src/shared/providers/ — QueryProvider, ThemeProvider +``` + +## Терминология API (НОВАЯ) + +| Термин | Описание | Старое название | +|---|---|---| +| `locations` | Точки обслуживания | points | +| `employees` | Сотрудники | staff | +| `bookings` | Записи/бронирования | bagsies | +| `organization_id` | UUID организации | network_code | +| `location_id` | UUID точки | point_code | +| `employee_id` | UUID сотрудника | master_phone | + +## Ролевая модель + +Роли: `owner`, `manager`, `staff` + атрибуты ABAC (`can_provide_services`, `can_manage_location_schedule`). + +## API конвенции + +- Базовый URL: `NEXT_PUBLIC_API_URL` +- Prefix: `/api/v1/` +- Auth: Bearer token в header `Authorization` +- Timestamps: ISO 8601 + TZ offset (`2025-01-25T14:00:00.000+05:00`) +- Schedule time-of-day: epoch `1970-01-01T09:00:00.000+05:00` + +## Ключевые паттерны + +- **API клиент**: `src/shared/api/client.ts` — кастомный fetch-клиент с mutex на refresh +- **Серверный стейт**: TanStack Query v5 (staleTime: 5 мин) +- **Локальный стейт**: Zustand (календарь) +- **Формы**: react-hook-form + Zod +- **i18n**: next-intl, локали `ru` (default) / `kz`, файлы в `messages/` +- **Токены**: cookies (access 15 мин, refresh 30 дней) + +## Документация + +Подробная документация в `docs/`: +- `PROJECT_OVERVIEW.md` — архитектура и стек +- `API_ENDPOINTS.md` — все эндпоинты из swagger +- `API_MIGRATION.md` — маппинг старый → новый API +- `API_TIMESTAMPS.md` — конвенция timestamp +- `BACKEND_TABLES.md` — структура БД +- `REGISTRATION_FLOW.md` — регистрация владельца (лендинг) +- `PLANS.md` — тарифы +- `FLOW_SOLO/POINT/NETWORK.md` — пользовательские флоу +- `FLOW_ADD_MASTER.md` — добавление мастера + +## Важно + +- Регистрация — в другом проекте (лендинг bagsy.kz), в ЛК её НЕ реализуем +- Часть GET-эндпоинтов (`/users/me`, `/employees`, `/locations`, `/services`) скоро появятся — не ломать текущую логику +- Телефон уникален, но используются UUID для всех сущностей diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md new file mode 100644 index 0000000..7e68b73 --- /dev/null +++ b/docs/API_ENDPOINTS.md @@ -0,0 +1,280 @@ +# API Endpoints + +Базовый URL: `NEXT_PUBLIC_API_URL` (например `https://api.bagsy.kz`) +Авторизация: `Authorization: Bearer ` (помечено как 🔒) + +--- + +## Auth + +### `POST /api/v1/auth/login` +Вход сотрудника по телефону и паролю. +``` +Request: { phone: string, password: string } +Response: { access_token: string, refresh_token: string } +Errors: 400, 401, 500 +``` + +### `POST /api/v1/auth/logout` +Инвалидация refresh-токена. +``` +Request: { refresh_token: string } +Response: 204 No Content +Errors: 400, 500 +``` + +### `POST /api/v1/auth/refresh` +Ротация токенов. ⚠️ Обязательно `Content-Type: application/json`. +``` +Request: { refresh_token: string } +Response: { access_token: string, refresh_token: string } +Errors: 400, 401, 500 +``` + +### `POST /api/v1/auth/register` +⚠️ Только лендинг (bagsy.kz), НЕ в ЛК. +``` +Request: { phone, password, first_name, last_name, plan_code } +Response: { message, phone, expires_in, retry_after } +Errors: 400, 409, 500 +``` + +### `POST /api/v1/auth/register/verify` +⚠️ Только лендинг. +``` +Request: { phone, code } +Response: { access_token, refresh_token } +``` + +### `POST /api/v1/auth/register/resend` +⚠️ Только лендинг. +``` +Request: { phone } +Response: { message, expires_in, retry_after } +``` + +### `GET /api/v1/auth/verify/{token}` +Проверка action-токена (инвайт, сброс пароля). +``` +Params: token (path) +Response: { phone, purpose, organization_id, location_id } +Errors: 400, 404, 500 +``` + +### `POST /api/v1/auth/password/reset` +Запрос сброса пароля (отправляет ссылку). +``` +Request: { phone } +Response: { message } +Errors: 400, 403, 404, 500 +``` + +### `POST /api/v1/auth/password/reset/confirm` +Подтверждение сброса пароля. +``` +Request: { token, new_password } +Response: { access_token, refresh_token } +Errors: 400, 401, 500 +``` + +--- + +## Bookings + +### `POST /api/v1/bookings` +Создание записи на услугу. +``` +Request: { phone, first_name, last_name, comment?, employee_id, location_id, service_id, start_at } +Response: { id: string } +Errors: 400, 409, 500 +``` + +### `POST /api/v1/bookings/slots` +Получение доступных слотов. +``` +Request: { location_id, service_id, start_date, end_date, employee_id? } +Response: { + location_id, service_id, duration_minutes, + master_slots: [{ employee_id, employee_name, price, slots: [{ start_at, end_at }] }] +} +Errors: 400, 404, 500 +``` + +### 🔒 `GET /api/v1/bookings/calendar` +Календарь записей за период. +``` +Params: from (required), to (required), location_id?, employee_id?, status? +Response: { + calendar: [{ + appointment_id, status, start_at, end_at, duration_minutes, price, + service_id, service_name, service_color, + employee_id, employee_name, + location_id, location_name, + customer_id, customer_name, customer_phone, customer_comment + }] +} +Errors: 400, 401, 403, 500 +``` + +### `POST /api/v1/bookings/{id}/confirm` +Подтверждение записи OTP-кодом. +``` +Params: id (path) +Request: { code: string } +Response: 204 No Content +Errors: 400, 404, 500 +``` + +### 🔒 `POST /api/v1/bookings/{id}/cancel` +Отмена записи. +``` +Params: id (path) +Request: { reason?: string } +Response: 204 No Content +Errors: 400, 403, 404, 500 +``` + +### `POST /api/v1/bookings/{id}/resend-otp` +Повторная отправка OTP подтверждения. +``` +Params: id (path) +Response: 204 No Content +Errors: 400, 404, 500 +``` + +--- + +## Employees + +### 🔒 `GET /api/v1/employees` +Список сотрудников с фильтрацией. +``` +Params: location_id?, role?, phone_search?, limit?, offset? +Response: { employees: [IEmployeeDto], total: number } +``` + +### 🔒 `GET /api/v1/employees/me` +Текущий сотрудник (замена `/users/me`). +``` +Response: IEmployeeDto +``` + +### 🔒 `PUT /api/v1/employees/me` +Обновление профиля. +``` +Request: { first_name, last_name, avatar_id? } +Response: IEmployeeDto +``` + +### 🔒 `POST /api/v1/employees/invite` +Приглашение сотрудника. +``` +Request: { phone, first_name, last_name, role: "manager"|"staff", location_id } +Response: { message, phone, expires_in } +Errors: 400, 401, 403, 409, 429, 500 +``` + +### `POST /api/v1/employees/invite/confirm` +Подтверждение приглашения + установка пароля. +``` +Request: { token, password } +Response: { access_token, refresh_token } +Errors: 400, 404, 409, 410, 500 +``` + +### 🔒 `POST /api/v1/employees/invite/resend` +Повторная отправка приглашения. +``` +Request: { phone } +Response: { message, phone, expires_in, retry_after } +Errors: 400, 401, 403, 404, 429, 500 +``` + +--- + +## Locations + +### 🔒 `POST /api/v1/locations` +Создание локации. +``` +Request: { + name, description?, phone, category_id, + latitude, longitude, + schedule_type: "mixed"|"fixed", + slot_duration_minutes: 5|10|15|30|60, + address: { city, street, building, details? } +} +Response: { id: string, prompt_org_profile: boolean } +Errors: 400, 403, 500 +``` + +### 🔒 `GET /api/v1/locations` +Список локаций организации. +``` +Response: { locations: [ILocationDto] } +``` + +### 🔒 `GET /api/v1/locations/{id}` +Получение локации по ID. +``` +Response: ILocationDto +``` + +### `GET /api/v1/locations/categories` +Категории бизнеса для создания локации. +``` +Response: { categories: [{ id: string, name: string, slug: string, sort_order: number }] } +``` + +--- + +## Services + +### 🔒 `GET /api/v1/services/{location_id}` +Список услуг локации. +``` +Response: { services: [IServiceDto] } +``` + +### 🔒 `POST /api/v1/services` +Создание услуги. +``` +Request: { name, description, location_id, category_id, subcategory_id?, duration_minutes, color } +Response: IServiceDto +``` + +### `GET /api/v1/service-categories` +Дерево категорий услуг по типу бизнеса. +``` +Params: location_category_id (UUID) +Response: { categories: [{ id, name, sort_order, children: [...] }] } +``` + +--- + +## Employee Services + +### 🔒 `POST /api/v1/employee-services` +Привязка сотрудника к услуге. +``` +Request: { employee_id: string, price: string, service_id: string } +Response: { id: string } +Errors: 400, 401, 403, 409, 500 +``` + +--- + +## Media + +### 🔒 `POST /api/v1/media/upload` +Загрузка медиафайла (аватар и т.д.). +``` +Request: FormData { file, purpose: "avatars" } +Response: { id: string, url: string } +``` + +### 🔒 `DELETE /api/v1/media/avatar` +Удаление аватара. +``` +Response: 204 No Content +``` diff --git a/docs/API_MIGRATION.md b/docs/API_MIGRATION.md new file mode 100644 index 0000000..498a824 --- /dev/null +++ b/docs/API_MIGRATION.md @@ -0,0 +1,133 @@ +# API Migration: Старый → Новый (завершена) + +## Изменения терминологии + +| Старое | Новое | Описание | +|---|---|---| +| `points` | `locations` | Точки обслуживания → Локации | +| `point_code` | `location_id` (UUID) | Идентификатор локации | +| `staff` | `employees` | Сотрудники | +| `master_phone` | `employee_id` (UUID) | Идентификатор сотрудника | +| `bagsies` | `bookings` | Записи/бронирования | +| `network_code` | `organization_id` (UUID) | Идентификатор организации | +| `pointCode` (JS/TS) | `locationId` | Переменные и пропы | +| `selectedMasterPhone` | `selectedEmployeeId` | Store/контекст календаря | + +## Изменения ролей + +| Старые роли | Новые роли | +|---|---| +| `admin` | ❌ удалена | +| `net_manager` | ❌ удалена (заменена ABAC атрибутами) | +| `self_owner` | ❌ удалена | +| `manager` | `manager` ✅ | +| `staff` | `staff` ✅ | +| — | `owner` ✅ (новая, заменяет self_owner/net_manager) | + +Вместо множества ролей используется **ABAC**: роль + атрибуты (`can_provide_services`, `can_manage_location_schedule`). + +--- + +## Маппинг эндпоинтов + +### Auth + +| Старый путь | Новый путь | Изменения | +|---|---|---| +| `POST v1/auth/login` | `POST /api/v1/auth/login` | ✅ без изменений | +| (не было) | `POST /api/v1/auth/logout` | **Новый** | +| `POST v1/auth/refresh` | `POST /api/v1/auth/refresh` | Добавлен `/api` prefix; обязателен `Content-Type: application/json` | +| `GET v1/auth/verify-auth-token/:token` | `GET /api/v1/auth/verify/{token}` | Убраны `point_code`/`network_code`, добавлены `organization_id`/`location_id` | +| `POST v1/auth/password/change` | `POST /api/v1/auth/password/reset` | `change` → `reset` | +| `POST v1/auth/password/change/confirm` | `POST /api/v1/auth/password/reset/confirm` | `password` → `new_password` | +| `POST v1/auth/staff/register/confirm` | `POST /api/v1/employees/invite/confirm` | Переехал из auth в employees | + +### Bookings + +| Старый путь | Новый путь | Изменения | +|---|---|---| +| `POST v1/bagsies/master` | `POST /api/v1/bookings` | `client_phone` → `phone`, `master_phone` → `employee_id`, добавлен `location_id` | +| `GET v1/calendar` | `GET /api/v1/bookings/calendar` | `point_code` → `location_id`, `master_phone` → `employee_id`, плоская структура ответа | +| (не было) | `POST /api/v1/bookings/slots` | **Новый** — слоты | +| (не было) | `POST /api/v1/bookings/{id}/confirm` | **Новый** | +| (не было) | `POST /api/v1/bookings/{id}/cancel` | **Новый** ✅ реализован | +| (не было) | `POST /api/v1/bookings/{id}/resend-otp` | **Новый** | + +### Employees + +| Старый путь | Новый путь | Изменения | +|---|---|---| +| `POST v1/auth/staff/register` | `POST /api/v1/employees/invite` | `name/surname` → `first_name/last_name`, `point_code` → `location_id` | +| `POST v1/auth/staff/register/confirm` | `POST /api/v1/employees/invite/confirm` | `phone+password+token` → `token+password` | +| `GET v1/staff` | `GET /api/v1/employees` | Новый эндпоинт, params: `location_id`, `role`, `phone_search` | +| `GET v1/users/me` | `GET /api/v1/employees/me` | ✅ реализован | +| `PUT v1/users/me` | `PUT /api/v1/employees/me` | ✅ реализован | + +### Locations + +| Старый путь | Новый путь | Изменения | +|---|---|---| +| `POST v1/points` | `POST /api/v1/locations` | Новый request: `address {}`, `schedule_type`, `slot_duration_minutes` | +| `GET v1/points` | `GET /api/v1/locations` | ✅ реализован | +| `GET v1/points/:code` | `GET /api/v1/locations/{id}` | ✅ реализован | +| (не было) | `GET /api/v1/locations/categories` | **Новый** ✅ реализован | +| (не было) | `GET /api/v1/service-categories?location_category_id=` | **Новый** ✅ реализован | + +### Services + +| Старый путь | Новый путь | Изменения | +|---|---|---| +| `GET v1/services/:point_code` | `GET /api/v1/services/{location_id}` | UUID вместо кода | +| `POST v1/services` | `POST /api/v1/services` | `location_id` вместо `point_code`, все ID строки (UUID) | +| `POST v1/master-services` | `POST /api/v1/employee-services` | `master_phone` → `employee_id`, `price` теперь string | + +### Media + +| Старый путь | Новый путь | Изменения | +|---|---|---| +| `POST v1/media/upload` | `POST /api/v1/media/upload` | ✅ реализован | +| (не было) | `DELETE /api/v1/media/avatar` | **Новый** ✅ реализован | + +--- + +## Формат ответа календаря + +### Старый (вложенный) +```json +{ + "data": [{ + "id": 1, + "bagsy_info": { "client_phone": "...", "master_phone": "..." }, + "service_info": { "name": "...", "color": "..." }, + "start_at": "...", "end_at": "..." + }] +} +``` + +### Новый (плоский) +```json +{ + "calendar": [{ + "appointment_id": "uuid", + "status": "confirmed", + "start_at": "2025-01-25T14:00:00.000+05:00", + "end_at": "2025-01-25T15:00:00.000+05:00", + "duration_minutes": 60, + "price": 5000, + "service_id": "uuid", "service_name": "Стрижка", "service_color": "#FF5733", + "employee_id": "uuid", "employee_name": "Анна", + "location_id": "uuid", "location_name": "Салон на Абая", + "customer_id": "uuid", "customer_name": "Иван", + "customer_phone": "77001234567", "customer_comment": "..." + }] +} +``` + +--- + +## Типы schedule_type + +| Значение | Описание | +|---|---| +| `fixed` | Все сотрудники работают по расписанию локации (SOLO: всегда fixed) | +| `mixed` | У локации своё расписание + у каждого сотрудника своё | diff --git a/docs/BACKEND_TABLES.md b/docs/BACKEND_TABLES.md new file mode 100644 index 0000000..b34c4b8 --- /dev/null +++ b/docs/BACKEND_TABLES.md @@ -0,0 +1,495 @@ +-------------------------------------- + -- Employees -- +-------------------------------------- + +CREATE TABLE employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + phone VARCHAR(20) NOT NULL, + password_hash VARCHAR(255), -- NULL до принятия инвайта + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100), + + organization_id UUID NOT NULL REFERENCES organizations(id), + location_id UUID NOT NULL REFERENCES locations(id), + + role VARCHAR(50) NOT NULL, -- owner, manager, staff + can_provide_services BOOLEAN DEFAULT false, + can_manage_location_schedule BOOLEAN DEFAULT false, + -- TODO: add other attributes -- + + is_active BOOLEAN DEFAULT true, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + + -- Сотрудник может быть только в одной организации + CONSTRAINT unique_active_employee_phone + UNIQUE (phone) WHERE is_active = true +); + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE TABLE employee_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE, + date DATE NOT NULL, + type VARCHAR(50) NOT NULL, -- work, rest -- + start_time TIME NOT NULL, + end_time TIME NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + CONSTRAINT valid_time_range CHECK (start_time < end_time), + + -- Запрет пересечений + CONSTRAINT no_overlapping_slots EXCLUDE USING gist ( + employee_id WITH =, + date WITH =, + timerange(start_time, end_time) WITH && + ) +); + + +CREATE TABLE employees_work_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + employee_id UUID NOT NULL REFERENCES employees(id), + organization_id UUID FOREIGN KEY REFERENCES organizations(id), + role VARCHAR(50) NOT NULL, -- Важно хранить, кем он был + joined_at TIMESTAMPTZ NOT NULL, -- Когда принял инвайт или сменил должность в этой же организации + fired_at TIMESTAMPTZ, -- Когда увелен с последнего метса работы + fire_reason TEXT +) + + + +-------------------------------------- + -- Customers -- +-------------------------------------- + + +CREATE TABLE customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + phone VARCHAR(20) UNIQUE NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100), + birth_date TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, +); + + -- База клиентов, куда салоны могут заполнять информацию о клиенте -- + -- Информация о клиенте распространяется на всю сеть (не на отдельные локации) -- +CREATE TABLE customers_base( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID FOREIGN KEY REFERENCES customers(id), + organization_id UUID NOT NULL REFERENCES organizations(id), + first_name VARCHAR(100), + last_name VARCHAR(100), + birth_date DATE, + gender VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + CONSTRAINT unique_customer_base_organization + UNIQUE (organization_id, customer_id) +); + +CREATE TABLE customers_notes( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_base_id UUID NOT NULL REFERENCES customers_base(id), + author_id UUID NOT NULL REFERENCES employees(id), + note TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + + + + +-------------------------------------- + -- Organizations -- +-------------------------------------- + +CREATE TABLE organizations( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID FOREIGN KEY REFERENCES employees(id), + name VARCHAR(255), + description TEXT, + slug VARCHAR(500), + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + + CONSTRAINT unique_organization_slug + UNIQUE (slug); +); + + +-- ═══════════════════════════════════════════════════════════════ +-- ПЛАНЫ (тарифы) +-- ═══════════════════════════════════════════════════════════════ + + +CREATE TABLE plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) UNIQUE NOT NULL, -- 'solo', 'business', 'enterprise' + name VARCHAR(100) NOT NULL, -- 'Solo', 'Business', 'Enterprise' + description TEXT, + + -- Цены + price_monthly DECIMAL(19,4) NOT NULL, + price_annual DECIMAL(19,4) NOT NULL, + + -- Мета + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +-- ═══════════════════════════════════════════════════════════════ +-- ФИЧИ ПЛАНОВ +-- ═══════════════════════════════════════════════════════════════ +-- Тут также будут лимиты: +-- Лимиты +-- max_locations INT NOT NULL DEFAULT 1, -- -1 = безлимит +-- max_employees INT NOT NULL DEFAULT 1, -- -1 = безлимит +-- max_services INT NOT NULL DEFAULT -1 + +CREATE TABLE plan_capabilities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plan_id UUID NOT NULL REFERENCES plans(id) ON DELETE CASCADE, + feature VARCHAR(100) NOT NULL, -- 'analytics', 'push_notifications', 'api_access' + + -- Лимит для фичи (опционально) + limit_value INT, -- NULL = безлимит, число = лимит + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + UNIQUE (plan_id, feature) +); +-- ═══════════════════════════════════════════════════════════════ +-- ПОДПИСКИ (ссылается на plan) +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + plan_id UUID NOT NULL REFERENCES plans(id), + billing_cycle VARCHAR(100) NOT NULL, + + -- !!! SNAPSHOT ЦЕНЫ !!! + -- Важно сохранять, по какой цене клиент подписался, даже без валюты. + -- Если завтра тариф станет дороже, этот клиент продолжит платить эту сумму. + recurring_amount DECIMAL(19,2) NOT NULL, + + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + trial_ends_at TIMESTAMPTZ, + next_billing_at TIMESTAMPTZ, + suspended_at TIMESTAMPTZ, + canceled_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + CONSTRAINT subscription_per_organization UNIQUE (organization_id) +); + +-- ═══════════════════════════════════════════════════════════════ +-- ПЛАТЕЖИ +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE subscription_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES subscriptions(id), + amount DECIMAL(19,4) NOT NULL, + status VARCHAR(100) NOT NULL,-- pending, success, failed, refunded -- + payment_provider VARCHAR(255), + external_payment_id VARCHAR(255), + paid_at TIMESTAMPTZ, + fail_reason TEXT, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ +); +------- + + + +-------------------------------------- + -- Locations -- +-------------------------------------- + +CREATE TABLE locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + category_id UUID NOT NULL REFERENCES location_categories(id), + + name VARCHAR(255) NOT NULL, + description TEXT, + slug VARCHAR(500), + city VARCHAR(255), + address TEXT, + + longitude DOUBLE PRECISION, + latitude DOUBLE PRECISION, + + is_active BOOLEAN DEFAULT true, + schedule_type VARCHAR(20), --fixed, mixed-- + slot_duration_minutes INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + + CONSTRAINT unique_location_slug + UNIQUE (slug) +); + + +CREATE TABLE location_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug VARCHAR(500) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT unique_location_categories_slug + UNIQUE (slug) +); + +CREATE EXTENSION IF NOT EXISTS btree_gist; + + +CREATE TABLE location_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE, + date DATE NOT NULL, + type VARCHAR(50) NOT NULL, -- work, rest -- + start_time TIME NOT NULL, + end_time TIME NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + CONSTRAINT valid_time_range CHECK (start_time < end_time), + + -- Запрет пересечений + CONSTRAINT no_overlapping_slots EXCLUDE USING gist ( + location_id WITH =, + date WITH =, + timerange(start_time, end_time) WITH && + ) +); + + + +-------------------------------------- + -- Services -- +-------------------------------------- + +CREATE TABLE service_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + location_category_id UUID NOT NULL REFERENCES location_categories(id), + parent_id UUID REFERENCES service_categories(id), -- Для подкатегорий + name VARCHAR(100) NOT NULL, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- TODO: maybe add slug -- +); + + +CREATE TABLE services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + location_id UUID REFERENCES locations(id), + category_id UUID NOT NULL REFERENCES service_categories(id), -- NULL = без категории + name VARCHAR(255) NOT NULL, + description TEXT, + duration_minutes INT NOT NULL, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, -- Временно скрыть + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ -- Удалить навсегда (soft для отчетов, аналитики) + + CONSTRAINT services_duration_positive CHECK (duration_minutes > 0), + CONSTRAINT services_sort_order_non_negative CHECK (sort_order >= 0), + CONSTRAINT services_name_not_empty CHECK (LENGTH(TRIM(name)) > 0) +); + +-- Уникальность имени услуги +CREATE UNIQUE INDEX idx_services_unique_name + ON services(location_id, LOWER(TRIM(name))) + WHERE deleted_at IS NULL; + + +-- Связь мастер-услуга (с индивидуальной ценой) +CREATE TABLE employee_services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE, + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + price DECIMAL(19, 4) NOT NULL, -- Цена этого мастера + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + UNIQUE (employee_id, service_id) +); + +-------------------------------------- + -- Appointment -- +-------------------------------------- + +CREATE TABLE appointments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + + -- ССЫЛКИ (Nullable для Hard Delete защиты) + location_id UUID REFERENCES locations(id) ON DELETE SET NULL, + service_id UUID REFERENCES services(id) ON DELETE SET NULL, + employee_id UUID REFERENCES employees(id) ON DELETE SET NULL, + customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, + + -- Начало и конец записи + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + + -- === SNAPSHOTS (ИСТОРИЯ) === + + -- 1. Финансы (Критично) + price DECIMAL(19, 4) NOT NULL, + -- Если добавите себестоимость или скидку, их snapshot тоже сюда: + -- discount_amount DECIMAL(19, 4) DEFAULT 0, + + -- 2. Услуга (Контекст) + service_name_snapshot VARCHAR(255) NOT NULL, + service_category_snapshot VARCHAR(100) NOT NULL, -- Добавлено для аналитики! + duration_minutes INTEGER NOT NULL, -- Snapshot длительности + + -- 3. Мастер (Контекст расчета ЗП) + employee_name_snapshot VARCHAR(255) NOT NULL, + employee_role_snapshot VARCHAR(50) NOT NULL, -- Добавлено: "Top Master" vs "Junior" + + -- 4. Клиент (Контактные данные на момент записи) + customer_name_snapshot VARCHAR(255) NOT NULL, + customer_phone_snapshot VARCHAR(20) NOT NULL, + + -- СТАТУСЫ И МЕТА + status VARCHAR(50) NOT NULL, + customer_comment TEXT, + cancelled_by UUID, + cancellation_reason VARCHAR(500), + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ +); + + +CREATE TABLE appointment_histories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE, + + from_status VARCHAR(50), -- pending, confirmed, in_progress, completed, cancelled + to_status VARCHAR(50) NOT NULL, -- pending, confirmed, in_progress, completed, cancelled + payload JSONB, + changed_by UUID, -- employee_id + reason TEXT, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + + +-------------------------------------- + -- Notification outbox -- +-------------------------------------- + +CREATE TABLE notification_outbox ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + + -- ОБЯЗАТЕЛЬНО ДОБАВИТЬ: ID сущности (Order ID, Appointment ID) + entity_id TEXT NOT NULL, + type TEXT NOT NULL, -- '24h_reminder', '1h_reminder', etc. + + payload JSONB NOT NULL, + scheduled_for TIMESTAMPTZ NOT NULL, + locked_until TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (id, scheduled_for) +) PARTITION BY RANGE (scheduled_for); + +-- Пример запроса для воркера уведомлений +-- WITH batch AS ( +-- SELECT id, scheduled_for +-- FROM notification_outbox +-- WHERE scheduled_for <= NOW() +-- AND (locked_until IS NULL OR locked_until < NOW()) -- Берем новые или "протухшие" +-- ORDER BY scheduled_for ASC +-- LIMIT 50 -- Размер пачки +-- FOR UPDATE SKIP LOCKED -- Пропускаем то, что прямо сейчас лочат другие +-- ) +-- UPDATE notification_outbox q +-- SET locked_until = NOW() + INTERVAL '2 minutes' -- Время на попытку отправки +-- FROM batch b +-- WHERE q.id = b.id +-- AND q.scheduled_for = b.scheduled_for -- ВАЖНО для partition pruning! +-- RETURNING q.id, q.scheduled_for, q.type, q.payload; +-- Удаление (Cleanup) +-- Здесь есть нюанс. Нам нужно удалить записи по составному ключу (id, scheduled_for). +-- Простой DELETE WHERE id IN (...) будет медленным, так как Postgres будет сканировать все партиции. + +-- func (w *Worker) deleteTasks(ctx context.Context, ids []int64, dates []time.Time) error { +-- // Мы используем UNNEST, чтобы удалить пачку за один запрос +-- // и при этом использовать индекс по (id, scheduled_for) +-- +-- query := ` +-- DELETE FROM notification_outbox +-- WHERE (id, scheduled_for) IN ( +-- SELECT * FROM UNNEST($1::bigint[], $2::timestamptz[]) +-- ) +-- ` +-- +-- _, err := w.db.ExecContext(ctx, query, pq.Array(ids), pq.Array(dates)) +-- return err +-- } + + +SELECT partman.create_parent( + -- 1. Имя нашей таблицы + p_parent_table => 'public.notification_outbox', + + -- 2. Поле, по которому режем (обязательно должно быть в PK) + p_control => 'scheduled_for', + + -- 3. Тип партиционирования (native - это современный декларативный стиль Postgres) + p_type => 'native', + + -- 4. Интервал: 1 день (оптимально для очистки) + p_interval => '1 day', + + -- 5. Самое важное для тебя: PREMAKE + -- Сколько таблиц держать готовыми ВПЕРЕД. + -- Ставим 30 (месяц). Это покрывает 90% записей. + -- Всё, что дальше 30 дней, упадет в _default таблицу. + p_premake => 30 +); + + +UPDATE partman.part_config +SET + -- Удалять партиции, где все записи старше 7 дней + retention = '7 days', + + -- false = DROP TABLE (физическое удаление, освобождает место сразу) + -- true = DETACH TABLE (просто отцепляет, таблица остается в базе) + retention_keep_table = false, + + -- Включаем автоматическое перемещение данных из DEFAULT партиции + -- в правильную партицию, когда она будет создана. + infinite_time_partitions = true +WHERE parent_table = 'public.notification_outbox'; diff --git a/docs/FLOW_ADD_MASTER.md b/docs/FLOW_ADD_MASTER.md new file mode 100644 index 0000000..a815d32 --- /dev/null +++ b/docs/FLOW_ADD_MASTER.md @@ -0,0 +1,100 @@ +# ФЛОУ: Добавление сотрудника (инвайт) + +## 1. Отправка инвайта (в ЛК) + +Frontend (app.bagsy.kz/staff → [Добавить сотрудника]): +- ФИО, телефон +- Роль: `manager` | `staff` +- Локация (location_id) — UUID +- `can_provide_services` — чекбокс (нужно ли привязывать к услугам) + +API: `POST /api/v1/employees/invite` +```json +{ "phone": "+77001234567", "first_name": "Анна", "last_name": "Иванова", "role": "staff", "location_id": "uuid" } +``` + +На указанный телефон через WhatsApp/SMS приходит ссылка: +`app.bagsy.kz/invite/{token}` + +--- + +## 2. Проверка инвайта + +`GET /api/v1/auth/verify/{token}` → ответ: +```json +{ + "invite_valid": true, + "organization_id": "uuid", + "location_id": "uuid", + "role": "staff", + "phone": "+77001234567", + "expires_at": "2025-02-05T10:00:00Z" +} +``` + +Дополнительные поля (от бека, уточнить): +- `user_exists` — зарегистрирован ли пользователь +- `already_linked` — привязан ли уже к другой локации +- `organization_name`, `location_name` — для отображения в UI + +--- + +## 3. Сценарии принятия инвайта + +### Новый пользователь (`user_exists = false`) +``` +┌────────────────────────────────────────────┐ +│ Приглашение в "Салон на Абая" │ +│ Роль: Мастер │ +│ │ +│ ФИО: [________________] │ +│ Телефон: +7 771 123 4567 (readonly) │ +│ Пароль: [________________] │ +│ Повтор пароля: [________________] │ +│ │ +│ [ПРИНЯТЬ И ЗАРЕГИСТРИРОВАТЬСЯ] │ +└────────────────────────────────────────────┘ +``` + +API: `POST /api/v1/employees/invite/confirm` → `{ token, password }` +Ответ: `{ access_token, refresh_token }` + +### Существующий пользователь (`user_exists = true, already_linked = false`) +``` +┌────────────────────────────────────────────┐ +│ Приглашение в "Салон на Абая" │ +│ Роль: Мастер │ +│ │ +│ Телефон: +7 771 123 4567 │ +│ │ +│ [ПРИНЯТЬ] [ОТКЛОНИТЬ] │ +└────────────────────────────────────────────┘ +``` + +### Уже привязан к другой локации (`already_linked = true`) +``` +┌────────────────────────────────────────────┐ +│ ⚠️ Вы уже работаете в другой точке │ +│ │ +│ Текущая точка: "Салон Красоты Алма" │ +│ Новое приглашение: "Салон на Абая" │ +│ │ +│ Чтобы принять: │ +│ 1. Попросите менеджера отвязать вас │ +│ 2. Перейдите по ссылке повторно │ +│ │ +│ [ПОНЯТНО] │ +└────────────────────────────────────────────┘ +``` + +--- + +## 4. Повторная отправка инвайта + +API: `POST /api/v1/employees/invite/resend` → `{ phone }` + +--- + +## 5. Отвязка сотрудника (в ЛК) + +Кнопка "Отвязать" в карточке сотрудника → пуш-уведомление мастеру: "Вы были отвязаны от локации". diff --git a/docs/FLOW_NETWORK.md b/docs/FLOW_NETWORK.md new file mode 100644 index 0000000..8531c83 --- /dev/null +++ b/docs/FLOW_NETWORK.md @@ -0,0 +1,83 @@ +# ФЛОУ: NETWORK (Сеть локаций) + +## Регистрация + +Frontend (bagsy.kz/register?plan=network): +- ФИО (required) +- Телефон (required) +- Пароль (required) +- Подтверждение OTP кода + +Backend автоматически создает: +- ✓ User +- ✓ Organization (tier: NETWORK, owner_id: user.id, name: NULL) +- ✓ Employee (role: owner, can_provide_services: false) +- ✓ Триал (trial_ends_at: now() + 2 months) + +Редирект: app.bagsy.kz/onboarding + +--- + +## Онбординг: Создание первой локации + +Аналогично POINT. Backend: `POST /api/v1/locations` + +Редирект → onboarding/add-services → dashboard + +--- + +## Создание второй локации (триггер сети) + +Frontend (app.bagsy.kz/locations → [+ Добавить локацию]): + +Система проверяет: +``` +if (tier == "NETWORK" && locations.count >= 1 && organization.name == NULL) + → показать модалку "Создание сети" +``` + +Модалка: +``` +┌────────────────────────────────────────────────┐ +│ 🏢 СОЗДАНИЕ СЕТИ │ +│ │ +│ Вы создаете сеть точек. Дайте ей название: │ +│ │ +│ Название сети (required) │ +│ Описание сети (optional) │ +│ │ +│ [Отмена] [Создать сеть и добавить локацию] │ +└────────────────────────────────────────────────┘ +``` + +Backend обновляет Organization: name, description, network_slug + +--- + +## Управление сетью (в ЛК) + +Frontend (app.bagsy.kz/locations) — доступно только owner: + +- Основная информация сети (название, описание) +- Статистика: количество локаций, сотрудников, записей, выручка +- Список всех локаций с кнопками управления +- [+ Добавить локацию] — до ~10 локаций + +--- + +## Добавление сотрудников + +Frontend (app.bagsy.kz/staff → [Добавить сотрудника]): +- ФИО, телефон +- Роль: `manager` (управляет локацией) | `staff` (оказывает услуги) +- Локация (select из `GET /api/v1/locations`) + +Backend (`POST /api/v1/employees/invite`): +- Отправляет инвайт-ссылку: app.bagsy.kz/invite/{token} + +--- + +## Запись клиента + +URL: bagsy.kz/{location_id} — запись в конкретную локацию. +Флоу аналогичен POINT: выбор услуги → дата/время → мастер → данные → OTP. diff --git a/docs/FLOW_POINT.md b/docs/FLOW_POINT.md new file mode 100644 index 0000000..a991464 --- /dev/null +++ b/docs/FLOW_POINT.md @@ -0,0 +1,84 @@ +# ФЛОУ: POINT (Локация с несколькими мастерами) + +## Регистрация + +Frontend (bagsy.kz/register?plan=point): +- ФИО (required) +- Телефон (required, маска +7) +- Пароль (required) +- Подтверждение OTP кода + +Backend автоматически создает: +- ✓ User +- ✓ Organization (tier: POINT, owner_id: user.id) +- ✓ Employee (role: owner, **can_provide_services: false** — по умолчанию) +- ✓ Триал (trial_ends_at: now() + 2 months) + +Редирект: app.bagsy.kz/onboarding + +--- + +## Онбординг: Создание локации + +Frontend форма: +- Название локации (required) +- Категория деятельности (required, select из `GET /api/v1/locations/categories`) +- Описание (optional) +- Адрес (optional) +- Расписание работы (required): дни недели + время + длина слота +- **schedule_type**: `mixed` или `fixed` + +✅ **Для POINT: чекбокс "Я сам оказываю услуги" ПОКАЗАН** + +Backend создает (`POST /api/v1/locations`): +- ✓ Location (organization_id, name, category_id, schedule_type, slot_duration_minutes) +- ✓ LocationSchedule + +Если чекбокс включен → `can_provide_services = true` для owner-employee + +Редирект: app.bagsy.kz/onboarding/add-services + +--- + +## Онбординг: Добавление услуг + +Frontend форма: +- Название услуги (required) +- Цена (required, ₸) +- Длительность (required, минуты) +- Категория (required, select из `GET /api/v1/service-categories?location_category_id=`) +- Описание (optional) +- Если owner.can_provide_services: `[ ] Я оказываю эту услугу` + +Backend создает (`POST /api/v1/services`): +- ✓ Service (location_id, name, duration, category_id) +- ✓ EmployeeService (service_id, employee_id=owner) — если чекбокс включен + +Редирект: app.bagsy.kz/dashboard + +--- + +## Добавление мастеров (в ЛК) + +Frontend (app.bagsy.kz/staff → [Добавить сотрудника]): +- ФИО (required) +- Телефон (required) +- Роль: `manager` | `staff` +- Локация (select из списка локаций) + +Backend (`POST /api/v1/employees/invite`): +- Отправляет ссылку-инвайт через WhatsApp: app.bagsy.kz/invite/{token} + +--- + +## Запись клиента (лендинг) + +URL: bagsy.kz/{location_id} + +1. Выбор услуги +2. Выбор даты и времени (слоты из `POST /api/v1/bookings/slots`) +3. **Выбор мастера** (для POINT — из списка мастеров, оказывающих эту услугу) +4. Данные клиента: ФИО, телефон, комментарий +5. Подтверждение OTP + +Backend: `POST /api/v1/bookings` diff --git a/docs/FLOW_SOLO.md b/docs/FLOW_SOLO.md new file mode 100644 index 0000000..60c0530 --- /dev/null +++ b/docs/FLOW_SOLO.md @@ -0,0 +1,78 @@ +# ФЛОУ: SOLO (Самозанятый) + +## Регистрация + +Frontend (bagsy.kz/register?plan=solo): +- ФИО (required) +- Телефон (required, маска +7) +- Пароль (required) +- Подтверждение OTP кода + +Backend автоматически создает: +- ✓ User +- ✓ Organization (tier: SOLO, owner_id: user.id, name: NULL) +- ✓ Employee (role: owner, **can_provide_services: true**) +- ✓ Триал (trial_ends_at: now() + 2 months) + +Редирект: app.bagsy.kz/onboarding + +--- + +## Онбординг: Создание локации + +Frontend форма: +- Название локации (required) +- Категория деятельности (required, select из `GET /api/v1/locations/categories`) +- Описание (optional) +- Адрес (optional, автокомплит) — можно не указывать для онлайн/выезда +- Расписание работы (required): дни недели + время + длина слота + +⚠️ **Для SOLO: чекбокс "Я сам оказываю услуги" СКРЫТ** (can_provide_services уже true) +⚠️ **schedule_type всегда `fixed`** (расписания синхронизируются автоматически на беке) + +Backend создает: +- ✓ Location (organization_id, name, category_id, address) +- ✓ LocationSchedule (расписание локации) +- ✓ EmployeeSchedule (автоматически копирует расписание на owner-employee) + +Редирект: app.bagsy.kz/onboarding/add-services + +--- + +## Онбординг: Добавление услуг + +Frontend форма (минимум 1 услуга): +- Название услуги (required) +- Цена (required, number, ₸) +- Длительность (required, number, минуты) +- Категория услуги (required, select из `GET /api/v1/service-categories?location_category_id=`) +- Описание (optional) + +Backend создает: +- ✓ Service (location_id, name, price, duration, category_id) +- ✓ EmployeeService (service_id, employee_id=owner) — привязка к owner через `POST /api/v1/employee-services` + +Редирект: app.bagsy.kz/dashboard + +--- + +## Синхронизация расписаний (SOLO) + +- При изменении расписания **локации** → автоматически обновляется расписание owner-employee +- При изменении расписания **owner-employee** → автоматически обновляется расписание локации +- Двусторонняя синхронизация только для SOLO + +--- + +## Запись клиента (лендинг) + +URL: bagsy.kz/{locale}/appointment/{location_id} + +1. Выбор услуги (карточки с ценой и длительностью) +2. Выбор даты (календарь) +3. Выбор времени (слоты из `POST /api/v1/bookings/slots`) +4. Мастер определен автоматически (для SOLO только owner) +5. Данные клиента: ФИО, телефон, комментарий +6. Подтверждение OTP кода + +Backend: `POST /api/v1/bookings` → Appointment (employee_id=owner, location_id, service_id) diff --git a/docs/PLANS.md b/docs/PLANS.md new file mode 100644 index 0000000..cd20b94 --- /dev/null +++ b/docs/PLANS.md @@ -0,0 +1,32 @@ +### SOLO - 5,000 ₸/мес + +├── 🎁 ПЕРВЫЕ 2 МЕСЯЦА БЕСПЛАТНО +├── 1 точка, 1 мастер +├── ♾️ Неограниченные записи +├── ✅ CRM базовая (карточки клиентов, история) +├── ✅ Уведомления клиентам (SMS/WhatsApp/Email) +├── ✅ Онлайн-запись +├── ✅ Напоминания о записях +└── Идеально для: самозанятых мастеров + +### POINT - 9,000 ₸/мес + +├── 🎁 ПЕРВЫЕ 1 МЕСЯЦА БЕСПЛАТНО +├── 1 точка, до 10 мастеров +├── Все из SOLO + +├── ✅ CRM +├── 📊 Аналитика и отчеты (в разработке) +├── 💰 Финансовый учет (в разработке) +├── 📦 Складской учет (в планах) +└── Идеально для: небольших салонов + +### NETWORK - 25,000 ₸/мес + +├── 🎁 ПЕРВЫЙ МЕСЯЦ БЕСПЛАТНО +├── ♾️ Неограниченно точек +├── ♾️ Неограниченно мастеров +├── Все из POINT + +├── 🏢 Мультифилиальность +├── 📊 Сводная аналитика по всем точкам +├── 🔗 API доступ (в планах, пока не реализуем) +└── Идеально для: сетей салонов \ No newline at end of file diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..b360356 --- /dev/null +++ b/docs/PROJECT_OVERVIEW.md @@ -0,0 +1,170 @@ +# Bagsy LK — Обзор проекта + +**Личный кабинет** (app.bagsy.kz) для управления салонами красоты: записи, сотрудники, услуги, локации, календарь. + +--- + +## Стек + +| Технология | Версия | Назначение | +|---|---|---| +| Next.js | 16 | App Router, SSR | +| React | 19 | UI | +| TypeScript | 5 | Типизация | +| TailwindCSS | 4 | Стили | +| TanStack Query | 5 | Серверный стейт | +| Zustand | 5 | Локальный стейт (календарь) | +| Zod | 4 | Валидация форм | +| react-hook-form | 7 | Формы | +| shadcn/ui (Radix) | — | UI-компоненты | +| next-intl | 4 | i18n (ru, kz) | +| next-themes | — | Тема (light/dark) | +| Serwist | 9 | PWA + Service Worker | +| date-fns | 4 | Даты | +| Leaflet | — | Карты (выбор адреса) | +| @dnd-kit | — | Drag & Drop в календаре | + +--- + +## Архитектура: Feature-Sliced Design (FSD) + +``` +app/ # Next.js App Router (страницы) +├── [locale]/(auth)/ # Логин, инвайт +├── [locale]/(sidebar)/ # Основные страницы с сайдбаром +│ ├── (dashboard)/ # Календарь (главная) +│ ├── staff/ # Сотрудники +│ ├── locations/ # Локации +│ ├── services/ # Услуги +│ ├── profile/ # Профиль +│ └── settings/ # Настройки + +src/ +├── widgets/ # Составные UI-блоки +│ ├── navigation/ # Сайдбар (app-sidebar, nav-main, nav-user) +│ ├── calendar-widget/ # Календарь (month/week/day/agenda views) +│ ├── forms/ # Phone input, dropdown +│ └── ui/ # Theme toggle, locale switcher +├── features/ # Бизнес-логика +│ ├── auth/ # LoginForm, InviteForm +│ ├── calendar/ # CalendarContext (Zustand), диалоги, настройки +│ ├── dashboard/ # DashboardPage, DashboardHeader +│ ├── staff/ # Таблица сотрудников, RegisterStaffForm +│ ├── locations/ # Таблица локаций, AddLocationForm, карта +│ ├── services/ # Таблица услуг, AddServiceForm, LocationSelect +│ ├── profile/ # Профиль пользователя +│ └── settings/ # Тема, локаль, push-уведомления +├── entities/ # UI-примитивы (shadcn/ui обёртки) +│ └── *.tsx # Button, Dialog, Input, Table, etc. +└── shared/ # Общая инфраструктура + ├── api/client.ts # HTTP-клиент (fetch, auto-refresh 401 + proactive refresh) + ├── services/ # API-сервисы + ├── hooks/ # React Query хуки + ├── types/ # TypeScript типы/интерфейсы + ├── schemas/ # Zod-схемы валидации + ├── utils/ # Утилиты (cookies, jwt, datetime, formatters) + └── providers/ # QueryProvider, ThemeProvider +``` + +--- + +## Ключевые файлы + +### API слой + +| Файл | Назначение | +|---|---| +| `src/shared/api/client.ts` | HTTP-клиент с auto-refresh 401 + proactive refresh | +| `src/shared/services/auth-service.ts` | Авторизация | +| `src/shared/services/calendar-service.ts` | Календарь записей | +| `src/shared/services/employee-service.ts` | Сотрудники (`GET /api/v1/employees`) | +| `src/shared/services/booking-service.ts` | Бронирования/записи | +| `src/shared/services/location-service.ts` | Локации | +| `src/shared/services/service-service.ts` | Услуги + категории | +| `src/shared/services/master-service.ts` | Привязка сотрудников к услугам | +| `src/shared/services/media-service.ts` | Загрузка медиа (S3 presigned URL) | + +### Хуки (React Query) + +| Файл | Хуки | +|---|---| +| `src/shared/hooks/use-auth.ts` | useLogin, useLogout, usePasswordReset | +| `src/shared/hooks/use-users.ts` | useCurrentUser, useUpdateProfile | +| `src/shared/hooks/use-calendar.ts` | useCalendar (main hook) | +| `src/shared/hooks/user-staff.ts` | useGetEmployees, useInviteEmployee | +| `src/shared/hooks/use-services.ts` | useLocationServices, useServiceCategories, useCreateService | +| `src/shared/hooks/use-network-points.ts` | useLocations, useLocation, useCreateLocation, useLocationCategories | +| `src/shared/hooks/use-bagsies.ts` | useCreateBooking, useCancelBooking | +| `src/shared/hooks/use-master-services.ts` | useCreateMasterService | + +### Типы + +| Файл | Содержимое | +|---|---| +| `src/shared/types/user.ts` | EUserRole, IEmployeeDto, IUserDto (deprecated) | +| `src/shared/types/calendar.ts` | IEvent, CalendarApiResponse, TCalendarView | +| `src/shared/types/staff.ts` | IStaffDto (deprecated) | + +### Утилиты + +| Файл | Назначение | +|---|---| +| `src/shared/utils/cookies.ts` | setAuthTokens, getAccessToken, clearAuthTokens | +| `src/shared/utils/jwt.ts` | decodeJwt (client-side) | +| `src/shared/utils/calendar-api-mapper.ts` | API → IEvent маппинг | +| `src/shared/utils/formater.ts` | parseTimestamp, formatTimestamp, toTimestampWithTz | + +--- + +## Авторизация + +1. Логин: `POST /api/v1/auth/login` → `{access_token, refresh_token}` +2. Токены хранятся в cookies: access (15 мин), refresh (30 дней) +3. При 401 — автоматический refresh через mutex в HttpClient +4. **Proactive refresh**: если access_token отсутствует (истёк cookie), но refresh_token есть — обновляем до запроса, не дожидаясь 401 +5. При неудачном refresh — редирект на `/{locale}/login` +6. JWT payload: `{ sub: employee_id, org: organization_id, role: string }` + +--- + +## Ролевая модель + +| Роль | Описание | +|---|---| +| `owner` | Владелец организации | +| `manager` | Менеджер локации | +| `staff` | Мастер/сотрудник | + +Дополнительно используются **атрибуты** (ABAC): +- `can_provide_services` — может оказывать услуги (у owner в SOLO плане = true) +- `can_manage_location_schedule` — может управлять расписанием локации + +--- + +## Терминология (актуальная) + +| Термин | Описание | +|---|---| +| `location` / `location_id` | Точка обслуживания (UUID) | +| `employee` / `employee_id` | Сотрудник (UUID) | +| `booking` | Запись/бронирование | +| `organization_id` | UUID организации | + +--- + +## i18n + +- Локали: `ru` (по умолчанию), `kz` +- Файлы: `messages/ru.json`, `messages/kz.json` +- Роутинг: `app/[locale]/...` +- Библиотека: `next-intl` + +--- + +## Окружение + +| Переменная | Назначение | +|---|---| +| `NEXT_PUBLIC_API_URL` | Базовый URL API | +| `NEXT_PUBLIC_PHONE_NUMBER` | Телефон поддержки | +| `NEXT_PUBLIC_DOMAIN` | Домен (для ссылок на terms/privacy) | diff --git a/docs/REGISTRATION_FLOW.md b/docs/REGISTRATION_FLOW.md new file mode 100644 index 0000000..42b8fdd --- /dev/null +++ b/docs/REGISTRATION_FLOW.md @@ -0,0 +1,323 @@ +# ТЗ: Регистрация владельца (Owner Registration) + +**Проект:** Bagsy +**Фича:** REG-001 — Регистрация владельца бизнеса +**Версия:** 3.0 (финал) +**Дата:** 15.02.2026 + +--- + +## 1. Краткое описание + +Владелец салона/самозанятый выбирает тариф на лендинге bagsy.kz и регистрируется. Система в одной транзакции создаёт пользователя, организацию и подписку. После регистрации владелец попадает в админку app.bagsy.kz. + +**Scope:** Только регистрация владельцев (owner). Мастера добавляются через invite-флоу (отдельная фича). + +--- + +## 2. User Flow + +``` +bagsy.kz (лендинг) + │ + ├─ Выбирает тариф → «Попробовать бесплатно» + │ → /register?plan=solo|point|network + │ + ├─ Или /register (без query) + │ + ▼ +bagsy.kz/register?plan={plan_code} + │ + ├─ Есть ?plan → тариф предвыбран (можно сменить) + ├─ Нет ?plan → селектор тарифа в форме + │ + ├─ Форма: + │ • Телефон (+7 ___ ___ __ __) [required] + │ • Имя [required] + │ • Фамилия [optional] + │ • Пароль [required, min 6] + │ • Подтвердите пароль [только фронт, на бэк не шлём] + │ • Тариф [required] + │ + │ Валидация фронт: zod + │ На бэк: phone (без +), first_name, last_name, password, plan_code + │ + ├─ POST /api/v1/auth/register + │ + ├─ форма с OTP для подтверждения номера + │ + ├─ POST api/v1/auth/register/verify + │ + + ▼ +Успех → Редирект app.bagsy.kz + │ + ▼ +Дашборд → «Создайте вашу первую точку» +``` + +--- + +## 3. API-контракт + +### `POST /api/v1/auth/register` + +**Request:** +```json +{ + "phone": "77001234567", + "first_name": "Айгуль", + "last_name": "Сериков", + "password": "mypass1", + "plan_code": "solo" +} +``` + +**Валидация на бэкенде:** + +| Поле | Правила | +|------|---------| +| `phone` | Required. Только цифры, 10–15 символов. Уникален среди `employees` с `is_active = true`. | +| `first_name` | Required. 1–100 символов. Trim пробелов. | +| `last_name` | Optional. 0–100 символов. Trim пробелов. | +| `password` | Required. Минимум 6 символов. | +| `plan_code` | Required. Существует в `plans` с `is_active = true`. | + +**Success (201):** +```json +{ + "message": "success" +} +``` + +**Errors:** + +| Code | Ситуация | Body | +|------|----------|------| +| 400 | Невалидные данные | `{ "error": "validation_error", "details": { "phone": "invalid_format" } }` | +| 409 | Телефон занят | `{ "error": "phone_already_exists" }` | +| 422 | Тариф не найден | `{ "error": "invalid_plan" }` | + +--- + +## 4. Бэкенд: одна транзакция + +Всё создаётся **в одной транзакции**. Если любой шаг падает — полный rollback, ничего не создано. + +``` +BEGIN; + +1. Проверить уникальность телефона +2. Хешировать пароль (bcrypt, cost 10) +3. INSERT organization (owner_id = NULL) → org_id +4. INSERT employee (organization_id = org_id) → employee_id +5. UPDATE organization SET owner_id = employee_id +6. INSERT subscription (trial) +7. INSERT employees_work_history + +COMMIT; + +8. Сгенерировать JWT tokens (вне транзакции) +``` + +### Шаг 3: Organization + +```sql +INSERT INTO organizations (owner_id, name, description, slug, is_active) +VALUES (NULL, NULL, NULL, NULL, true) +RETURNING id; +``` + +### Шаг 4: Employee (owner) + +```sql +INSERT INTO employees ( + phone, password_hash, first_name, last_name, + organization_id, location_id, + role, can_provide_services, is_active +) VALUES ( + '77001234567', '$2a$10$...', 'Айгуль', 'Сериков', + :org_id, NULL, + 'owner', false, true +) RETURNING id; +``` + +### Шаг 5: Связать owner + +```sql +UPDATE organizations SET owner_id = :employee_id WHERE id = :org_id; +``` + +### Шаг 6: Subscription (trial) + +```sql +INSERT INTO subscriptions ( + organization_id, plan_id, billing_cycle, + recurring_amount, + current_period_start, current_period_end, + trial_ends_at, next_billing_at +) VALUES ( + :org_id, :plan_id, 'monthly', + :plan_price_monthly, + NOW(), NOW() + INTERVAL '2 months', + NOW() + INTERVAL '2 months', + NOW() + INTERVAL '2 months' +); +``` + +### Шаг 7: Work history + +```sql +INSERT INTO employees_work_history ( + employee_id, organization_id, role, joined_at +) VALUES (:employee_id, :org_id, 'owner', NOW()); +``` + +### Шаг 8: JWT tokens (вне транзакции) + +```json +{ + "sub": "employee_id", + "org": "organization_id", + "role": "owner" +} +``` + +--- + +## 5. Миграции + +### 5.1. `employees.organization_id` → NULLABLE + +```sql +-- Было: +organization_id UUID NOT NULL REFERENCES organizations(id), +-- Стало: +organization_id UUID REFERENCES organizations(id), +``` + +> Хоть в регистрации мы ставим org_id сразу, nullable нужен для invite-флоу: мастер создаётся по инвайту и может временно быть без организации. + +### 5.2. `employees.location_id` → NULLABLE + +```sql +-- Было: +location_id UUID NOT NULL REFERENCES locations(id), +-- Стало: +location_id UUID REFERENCES locations(id), +``` + +> Owner при регистрации не имеет точки. Привязка к точке — отдельный шаг. + +--- + +## 6. Бизнес-правила + +| Правило | Описание | +|---------|----------| +| Уникальность телефона | Только среди `is_active = true`. Уволенный сотрудник с тем же номером не блокирует регистрацию. | +| Пароль | bcrypt cost 10+. Plaintext не логируется. | +| Телефон в БД | Хранится без «+», только цифры: `77001234567` | +| Триал | 2 месяца, стартует при регистрации. Полный функционал тарифа. | +| Org без имени | `name`, `slug`, `description` = NULL. Заполняются позже в админке (для Network — обязательно при создании 2-й точки). | +| Owner can_provide_services | По умолчанию `false`. Включается при создании точки (чекбокс «Я сам оказываю услуги»). | +| Rate limit | 5 req/min с одного IP на `/auth/register`. | + +--- + +## 7. Дашборд после регистрации (контекст для фронта) + +| Условие | Подсказка | +|---------|-----------| +| `locations.count == 0` | «Создайте вашу первую точку» | +| `locations.count > 0 && services.count == 0` | «Добавьте услуги» | +| `services.count > 0 && employees.count == 1 && plan ≠ solo` | «Пригласите мастеров» | + +--- + +## 8. Тест-кейсы + +### Happy Path +1. ✅ Регистрация с `?plan=solo` → 201, всё создано +2. ✅ Регистрация без query, тариф в body → 201 +3. ✅ Автологин после регистрации, JWT валиден +4. ✅ Organization создана с `owner_id`, `name = NULL` +5. ✅ Subscription создана с `trial_ends_at = NOW() + 2 months` + +### Валидация +6. ❌ Телефон с буквами → 400 +7. ❌ Телефон 8 цифр → 400 +8. ❌ Пароль 5 символов → 400 +9. ❌ `first_name` пустое → 400 +10. ❌ `plan_code` несуществующий → 422 + +### Конфликты +11. ❌ Тот же телефон (active employee) → 409 +12. ✅ Телефон уволенного (`is_active = false`) → 201 + +### Транзакционность +13. ✅ Если INSERT subscription падает → employee и organization откатываются +14. ✅ После rollback можно зарегаться повторно (ничего не осталось) + +### Безопасность +15. ✅ Пароль = bcrypt hash в БД +16. ✅ 6-й запрос за минуту → 429 + +--- + +## 9. Зависимости + +| Зависимость | Блокирует? | +|-------------|------------| +| Seed data: таблица `plans` заполнена | Да | +| Миграция: `employees.organization_id` nullable | Да | +| Миграция: `employees.location_id` nullable | Да | +| JWT-инфраструктура | Да | + +--- + +## 10. Seed Data + +```sql +INSERT INTO plans (code, name, description, price_monthly, price_annual, sort_order, is_active) +VALUES + ('solo', 'Solo', 'Для самозанятых мастеров', 5000.00, 48000.00, 1, true), + ('point', 'Point', 'Для одной точки с сотрудниками', 9000.00, 86400.00, 2, true), + ('network', 'Network', 'Для сети из нескольких точек', 25000.00, 240000.00, 3, true); + +INSERT INTO plan_capabilities (plan_id, feature, limit_value) VALUES + ((SELECT id FROM plans WHERE code = 'solo'), 'max_locations', 1), + ((SELECT id FROM plans WHERE code = 'solo'), 'max_employees', 1), + ((SELECT id FROM plans WHERE code = 'solo'), 'max_services', -1), + ((SELECT id FROM plans WHERE code = 'solo'), 'online_booking', NULL), + ((SELECT id FROM plans WHERE code = 'solo'), 'whatsapp_notifications', NULL), + + ((SELECT id FROM plans WHERE code = 'point'), 'max_locations', 1), + ((SELECT id FROM plans WHERE code = 'point'), 'max_employees', 10), + ((SELECT id FROM plans WHERE code = 'point'), 'max_services', -1), + ((SELECT id FROM plans WHERE code = 'point'), 'online_booking', NULL), + ((SELECT id FROM plans WHERE code = 'point'), 'whatsapp_notifications', NULL), + ((SELECT id FROM plans WHERE code = 'point'), 'client_base', NULL), + ((SELECT id FROM plans WHERE code = 'point'), 'analytics_basic', NULL), + + ((SELECT id FROM plans WHERE code = 'network'), 'max_locations', -1), + ((SELECT id FROM plans WHERE code = 'network'), 'max_employees', -1), + ((SELECT id FROM plans WHERE code = 'network'), 'max_services', -1), + ((SELECT id FROM plans WHERE code = 'network'), 'online_booking', NULL), + ((SELECT id FROM plans WHERE code = 'network'), 'whatsapp_notifications', NULL), + ((SELECT id FROM plans WHERE code = 'network'), 'client_base', NULL), + ((SELECT id FROM plans WHERE code = 'network'), 'analytics_advanced', NULL), + ((SELECT id FROM plans WHERE code = 'network'), 'multi_location_management', NULL); +``` + +--- + +## 11. Вне scope + +- Логин — отдельная фича +- Refresh tokens — отдельная фича +- Сброс пароля — отдельная фича +- Создание точки — отдельная фича +- Invite-флоу мастеров — отдельная фича +- B2C-регистрация клиентов (OTP) — отдельная фича +- Upgrade/downgrade тарифа — отдельная фича diff --git a/messages/kz.json b/messages/kz.json index 8dbeb19..cd03f8a 100644 --- a/messages/kz.json +++ b/messages/kz.json @@ -13,7 +13,7 @@ "clients": "Клиенттері", "services": "Қызметтер", "staff": "Жұмысшылар", - "points": "Орындар", + "locations": "Локациялар", "analytics": "Аналитика", "settings": "Параметрлер" }, @@ -38,7 +38,7 @@ "welcome": "Кош келдіңіз!", "panel": "Панель {name}" }, - "emptyPoints": { + "emptyLocations": { "title": "Қызмет көрсету нүктелері жоқ", "description": "Сізде әлі қызмет көрсету нүктелері жоқ. Күнтізбемен жұмыс істеу үшін бірінші нүктені қосыңыз.", "addPointButton": "Нүкте қосу" @@ -120,10 +120,19 @@ }, "EventDetailsDialog": { "edit": "Өңдеу", + "cancel": "Жазбаны болдырмау", + "cancelConfirm": "Бұл жазбаны болдырмағыңыз келетініне сенімдісіз бе?", + "cancelReason": "Болдырмау себебі", + "cancelReasonPlaceholder": "Себебін көрсетіңіз (міндетті емес)", + "cancelSuccess": "Жазба болдырмалды", + "cancelError": "Жазбаны болдырмау мүмкін болмады", + "cancelling": "Болдырмау...", "responsible": "Қызметкер", "startDate": "Басталу күні және уақыты", "endDate": "Аяқталу күні және уақыты", - "comment": "Пікір" + "comment": "Пікір", + "client": "Клиент", + "status": "Күйі" }, "DayCell": { "more": "тағы" @@ -213,8 +222,8 @@ "active": "Белсенді", "role": "Рөл", "inactive": "Белсенді емес", - "pointCode": "Нүкте коды", - "networkCode": "Желі коды", + "locationId": "Локация", + "networkCode": "Ұйым", "createdAt": "Жасалған күні", "updatedAt": "Соңғы жаңарту", "editProfile": "Профильді өңдеу", @@ -241,7 +250,7 @@ "addStaff": "Жұмысшы қосу", "searchPlaceholder": "Аты, телефон, рөл бойынша іздеу...", "networkCode": "Желі коды", - "pointCode": "Нүкте коды", + "locationId": "Локация", "role": "Рөл", "name": "Аты", "surname": "Тегі", @@ -260,9 +269,9 @@ "errorLoadingData": "Деректерді жүктеу қатесі", "unknownError": "Белгісіз қате", "filters": { - "pointCode": "Нүкте коды", - "pointCodePlaceholder": "Нүкте кодын енгізіңіз", - "networkCode": "Желі коды", + "locationId": "Локация", + "locationIdPlaceholder": "Локация UUID енгізіңіз", + "networkCode": "Ұйым", "networkCodePlaceholder": "Желі кодын енгізіңіз", "role": "Рөл", "rolePlaceholder": "Рөлді таңдаңыз", @@ -274,12 +283,9 @@ "phones": "Телефондар" }, "roles": { - "staff": "Қызметкер", + "owner": "Иесі", "manager": "Менеджер", - "net_manager": "Желі менеджері", - "self_owner": "Иесі", - "admin": "Әкімші", - "worker": "Жұмысшы" + "staff": "Қызметкер" }, "pagination": { "showing": "Көрсетілген", @@ -299,16 +305,15 @@ "phonePlaceholder": "Телефон нөмірін енгізіңіз", "role": "Рөл", "rolePlaceholder": "Рөлді таңдаңыз", - "pointCode": "Нүкте коды", - "pointCodePlaceholder": "Нүкте кодын енгізіңіз", + "locationId": "Локация", + "locationIdPlaceholder": "Локацияны таңдаңыз", "submit": "Жұмысшы қосу", "submitting": "Қосуда...", "cancel": "Болдырмау", "success": "Жұмысшы сәтті қосылды. Тіркелгіні аяқтау үшін сізге телефон нөміріне сілтеме жіберілді.", "roles": { "staff": "Жұмысшы", - "manager": "Менеджер", - "net_manager": "Желі менеджері" + "manager": "Менеджер" }, "errors": { "nameMin": "Аты кемінде 2 таңбадан тұруы керек", @@ -318,13 +323,12 @@ "phoneRequired": "Телефон нөмірін енгізіңіз", "phoneInvalid": "Дұрыс емес телефон нөмірі", "roleRequired": "Рөлді таңдаңыз", - "pointCodeRequired": "Нүкте кодын енгізіңіз", - "pointCodeMax": "Нүкте коды кемінде 50 таңбадан тұруы керек", + "pointCodeRequired": "Локацияны таңдаңыз", "submitError": "Жұмысшы қосу қатесі" } } }, - "Points": { + "Locations": { "title": "Қызмет көрсету нүктелері", "tableTitle": "Қызмет көрсету нүктелері", "code": "Код", @@ -353,8 +357,28 @@ "addPointForm": { "name": "Атауы", "description": "Сипаттама", + "phone": "Байланыс нөмірі", "categoryId": "Категория", "categoryIdPlaceholder": "Категорияны таңдаңыз", + "scheduleType": "Кесте түрі", + "scheduleTypePlaceholder": "Түрін таңдаңыз", + "scheduleTypes": { + "mixed": { + "label": "Аралас", + "description": "Нүктенің өз кестесі, шеберлердің — өздерінікі", + "hint": "Әр шебер нүкте кестесінен бөлек өз жұмыс кестесін реттей алады" + }, + "fixed": { + "label": "Тұрақты", + "description": "Барлық шеберлер нүкте кестесі бойынша жұмыс істейді", + "hint": "Шеберлер өз кестесін өзгерте алмайды — ол нүкте кестесімен бірдей болады" + } + }, + "soloScheduleNote": "сіздің кестеңіз бен нүкте кестесі автоматты түрде синхрондалады", + "slotDuration": "Слот ұзақтығы", + "slotDurationPlaceholder": "Ұзақтығын таңдаңыз", + "slotDurationHint": "Жазылу мүмкін болатын аралықтар. Мысалы, әр 30 минут немесе әр 5 минут сайын", + "minutes": "мин", "address": { "title": "Мекенжай", "autocomplete": "Мекенжайды таңдаңыз, қала және көше автоматты түрде толтырылады, бірақ сіз оларды өзгерте аласыз", @@ -367,13 +391,10 @@ "mapNoLocation": "Картада көрсету үшін мекенжайды таңдаңыз", "city": "Қала", "street": "Көше", - "latitude": "Ендік", - "longitude": "Бойлық" - }, - "schedule": { - "title": "Кесте", - "allDay": "Күні бойы", - "comment": "Түсініктеме" + "building": "Ғимарат / корпус", + "buildingPlaceholder": "Үй нөмірі, корпус", + "details": "Қосымша ақпарат", + "detailsPlaceholder": "Қабат, кеңсе, кіреберіс және т.б." }, "submit": "Құру", "submitting": "Құрылуда...", @@ -383,17 +404,16 @@ "nameMin": "Атауы кемінде 2 таңбадан тұруы керек", "nameMax": "Атауы 100 таңбадан аспауы керек", "descriptionMax": "Сипаттама 500 таңбадан аспауы керек", + "phoneRequired": "Телефонды көрсетіңіз", "categoryIdRequired": "Категорияны таңдау қажет", + "scheduleTypeRequired": "Кесте түрін таңдаңыз", + "slotDurationMin": "Кемінде 5 минут", + "slotDurationMax": "Ең көбі 480 минут", "cityMin": "Қала атауы кемінде 2 таңбадан тұруы керек", "cityMax": "Қала атауы 100 таңбадан аспауы керек", "streetMin": "Көше атауы кемінде 2 таңбадан тұруы керек", "streetMax": "Көше атауы 200 таңбадан аспауы керек", - "latitudeMin": "Ендік -90-дан кем болмауы керек", - "latitudeMax": "Ендік 90-дан аспауы керек", - "longitudeMin": "Бойлық -180-ден кем болмауы керек", - "longitudeMax": "Бойлық 180-ден аспауы керек", "addressRequired": "Іздеуден мекенжайды таңдау қажет", - "scheduleMin": "Кемінде бір күнге кестені көрсету қажет", "networkCodeRequired": "Желі кодын анықтау мүмкін болмады", "submitError": "Нүктені құру кезінде қате" } @@ -418,9 +438,9 @@ "status": "Күйі", "masters": "Шеберлер" }, - "pointSelect": { - "label": "Қызмет көрсету нүктесі", - "placeholder": "Нүктені таңдаңыз" + "locationSelect": { + "label": "Локация", + "placeholder": "Локацияны таңдаңыз" }, "addServiceDialog": { "title": "Қызмет қосу", @@ -466,7 +486,7 @@ "durationMustBePositive": "Ұзақтық оң сан болуы керек", "colorRequired": "Түсті таңдаңыз", "colorMax": "Түс 50 таңбадан аспауы керек", - "pointCodeRequired": "Нүкте кодын анықтау мүмкін болмады", + "pointCodeRequired": "Локацияны анықтау мүмкін болмады", "submitError": "Қызмет құру кезінде қате" } }, diff --git a/messages/ru.json b/messages/ru.json index e7f4bbc..cec3847 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -13,7 +13,7 @@ "clients": "Клиенты", "services": "Услуги", "staff": "Сотрудники", - "points": "Точки", + "locations": "Локации", "analytics": "Аналитика", "settings": "Настройки" }, @@ -37,7 +37,7 @@ "welcome": "Добро пожаловать!", "calendar": "Календарь" }, - "emptyPoints": { + "emptyLocations": { "title": "Нет точек обслуживания", "description": "У вас пока нет точек обслуживания. Добавьте первую точку, чтобы начать работу с календарем.", "addPointButton": "Добавить точку" @@ -119,10 +119,19 @@ }, "EventDetailsDialog": { "edit": "Редактировать", + "cancel": "Отменить запись", + "cancelConfirm": "Вы уверены, что хотите отменить эту запись?", + "cancelReason": "Причина отмены", + "cancelReasonPlaceholder": "Укажите причину (необязательно)", + "cancelSuccess": "Запись отменена", + "cancelError": "Не удалось отменить запись", + "cancelling": "Отмена...", "responsible": "Ответственный", "startDate": "Дата и время начала", "endDate": "Дата и время окончания", - "comment": "Комментарий" + "comment": "Комментарий", + "client": "Клиент", + "status": "Статус" }, "DayCell": { "more": "ещё" @@ -214,8 +223,8 @@ "active": "Активен", "role": "Роль", "inactive": "Неактивен", - "pointCode": "Код точки", - "networkCode": "Код сети", + "locationId": "Локация", + "networkCode": "Организация", "createdAt": "Дата создания", "updatedAt": "Последнее обновление", "editProfile": "Редактировать профиль", @@ -242,7 +251,7 @@ "addStaff": "Добавить сотрудника", "searchPlaceholder": "Поиск по имени, телефону, роли...", "networkCode": "Код сети", - "pointCode": "Код точки", + "locationId": "Локация", "role": "Роль", "name": "Имя", "surname": "Фамилия", @@ -261,9 +270,9 @@ "errorLoadingData": "Ошибка загрузки данных", "unknownError": "Неизвестная ошибка", "filters": { - "pointCode": "Код точки", - "pointCodePlaceholder": "Введите код точки", - "networkCode": "Код сети", + "locationId": "Локация", + "locationIdPlaceholder": "Введите UUID локации", + "networkCode": "Организация", "networkCodePlaceholder": "Введите код сети", "role": "Роль", "rolePlaceholder": "Выберите роль", @@ -274,12 +283,9 @@ "phones": "Телефоны" }, "roles": { - "staff": "Сотрудник", + "owner": "Владелец", "manager": "Менеджер", - "net_manager": "Сетевой менеджер", - "self_owner": "Владелец", - "admin": "Администратор", - "worker": "Работник" + "staff": "Сотрудник" }, "pagination": { "showing": "Показано", @@ -299,16 +305,15 @@ "phonePlaceholder": "Введите номер телефона", "role": "Роль", "rolePlaceholder": "Выберите роль", - "pointCode": "Код точки", - "pointCodePlaceholder": "Введите код точки", + "locationId": "Локация", + "locationIdPlaceholder": "Выберите локацию", "submit": "Добавить сотрудника", "submitting": "Добавление...", "cancel": "Отмена", "success": "Сотрудник успешно добавлен. Ссылка для завершения регистрации отправлена.", "roles": { "staff": "Сотрудник", - "manager": "Менеджер", - "net_manager": "Сетевой менеджер" + "manager": "Менеджер" }, "errors": { "nameMin": "Имя должно содержать минимум 2 символа", @@ -318,15 +323,14 @@ "phoneRequired": "Введите номер телефона", "phoneInvalid": "Некорректный номер телефона", "roleRequired": "Выберите роль", - "pointCodeRequired": "Введите код точки", - "pointCodeMax": "Код точки не должен превышать 50 символов", + "pointCodeRequired": "Выберите локацию", "submitError": "Ошибка при добавлении сотрудника" } } }, - "Points": { - "title": "Точки обслуживания", - "tableTitle": "Точки обслуживания", + "Locations": { + "title": "Локации", + "tableTitle": "Локации", "code": "Код", "name": "Название", "address": "Адрес", @@ -353,8 +357,28 @@ "addPointForm": { "name": "Название", "description": "Описание", + "phone": "Контактный номер", "categoryId": "Категория", "categoryIdPlaceholder": "Выберите категорию", + "scheduleType": "Тип расписания", + "scheduleTypePlaceholder": "Выберите тип", + "scheduleTypes": { + "mixed": { + "label": "Смешанное", + "description": "У локации своё расписание, у мастеров — своё", + "hint": "Каждый мастер сможет настроить свой график работы отдельно от расписания локации" + }, + "fixed": { + "label": "Фиксированное", + "description": "Все мастера работают по расписанию локации", + "hint": "Мастера не смогут менять свой график — он будет совпадать с расписанием локации" + } + }, + "soloScheduleNote": "ваше расписание и расписание локации синхронизируются автоматически", + "slotDuration": "Длительность слота", + "slotDurationPlaceholder": "Выберите длительность", + "slotDurationHint": "Промежутки, в которые будет доступна запись. Например, каждые 30 минут или каждые 5 минут", + "minutes": "мин", "address": { "title": "Адрес", "autocomplete": "Город и улица заполнятся автоматически при выборе адреса из поиска, но вы можете их изменить вручную", @@ -367,13 +391,10 @@ "mapNoLocation": "Выберите адрес для отображения на карте", "city": "Город", "street": "Улица", - "latitude": "Широта", - "longitude": "Долгота" - }, - "schedule": { - "title": "Расписание", - "allDay": "Весь день", - "comment": "Комментарий" + "building": "Здание / корпус", + "buildingPlaceholder": "Номер дома, корпус", + "details": "Доп. информация", + "detailsPlaceholder": "Этаж, офис, вход и т.д." }, "submit": "Создать", "submitting": "Создание...", @@ -383,19 +404,18 @@ "nameMin": "Название должно содержать минимум 2 символа", "nameMax": "Название не должно превышать 100 символов", "descriptionMax": "Описание не должно превышать 500 символов", + "phoneRequired": "Укажите телефон", "categoryIdRequired": "Необходимо выбрать категорию", + "scheduleTypeRequired": "Выберите тип расписания", + "slotDurationMin": "Минимум 5 минут", + "slotDurationMax": "Максимум 480 минут", "cityMin": "Название города должно содержать минимум 2 символа", "cityMax": "Название города не должно превышать 100 символов", "streetMin": "Название улицы должно содержать минимум 2 символа", "streetMax": "Название улицы не должно превышать 200 символов", - "latitudeMin": "Широта должна быть не менее -90", - "latitudeMax": "Широта должна быть не более 90", - "longitudeMin": "Долгота должна быть не менее -180", - "longitudeMax": "Долгота должна быть не более 180", "addressRequired": "Необходимо выбрать адрес из поиска", - "scheduleMin": "Необходимо указать расписание хотя бы для одного дня", "networkCodeRequired": "Не удалось определить код сети", - "submitError": "Ошибка при создании точки" + "submitError": "Ошибка при создании локации" } } }, @@ -418,9 +438,9 @@ "status": "Статус", "masters": "Мастера" }, - "pointSelect": { - "label": "Точка обслуживания", - "placeholder": "Выберите точку" + "locationSelect": { + "label": "Локация", + "placeholder": "Выберите локацию" }, "addServiceDialog": { "title": "Добавить услугу", @@ -466,7 +486,7 @@ "durationMustBePositive": "Длительность должна быть положительным числом", "colorRequired": "Выберите цвет", "colorMax": "Цвет не должен превышать 50 символов", - "pointCodeRequired": "Не удалось определить код точки", + "pointCodeRequired": "Не удалось определить локацию", "submitError": "Ошибка при создании услуги" } }, diff --git a/src/entities/select.tsx b/src/entities/select.tsx index c8cfb97..8ff14c9 100644 --- a/src/entities/select.tsx +++ b/src/entities/select.tsx @@ -118,7 +118,7 @@ const SelectItem = React.forwardRef< void; - selectedMasterPhone: IUserDto["phone"] | "all"; - setSelectedMasterPhone: (masterPhone: IUserDto["phone"] | "all") => void; + selectedEmployeeId: IEmployeeDto["id"] | "all"; + setSelectedEmployeeId: (employeeId: IEmployeeDto["id"] | "all") => void; badgeVariant: TBadgeVariant; setBadgeVariant: (variant: TBadgeVariant) => void; - masters: IUserDto[]; + masters: IEmployeeDto[]; workingHours: TWorkingHours; setWorkingHours: Dispatch>; visibleHours: TVisibleHours; setVisibleHours: Dispatch>; events: IEvent[]; setLocalEvents: Dispatch>; - /** Код точки (selectedPointCode || currentUser.point_code). Для выборов услуги в форме записи. */ - pointCode: string | undefined; + /** UUID локации (selectedLocationId || currentUser.location_id). */ + locationId: string | undefined; } export function CalendarProvider({ @@ -41,17 +41,17 @@ export function CalendarProvider({ events, initialDate, onDateChange, - onMasterPhoneChange, - selectedPointCode, + onEmployeeIdChange, + selectedLocationId, }: { children: React.ReactNode; - masters: IUserDto[]; + masters: IEmployeeDto[]; events: IEvent[]; initialDate?: Date; onDateChange?: (date: Date) => void; - onMasterPhoneChange?: (masterPhone: string | undefined) => void; - /** Выбранный код точки для net_manager и self_owner (приоритет над currentUser.point_code) */ - selectedPointCode?: string; + onEmployeeIdChange?: (employeeId: string | undefined) => void; + /** Выбранный UUID локации для owner (приоритет над currentUser.location_id) */ + selectedLocationId?: string; }) { const { data: currentUser } = useCurrentUser(); const setMasters = useCalendarStore((s: CalendarState) => s.setMasters); @@ -67,42 +67,40 @@ export function CalendarProvider({ const selectedDateValue = useCalendarStore( (s: CalendarState) => s.selectedDate ); - const selectedMasterPhone = useCalendarStore( - (s: CalendarState) => s.selectedMasterPhone + const selectedEmployeeId = useCalendarStore( + (s: CalendarState) => s.selectedEmployeeId ); - const setPointCode = useCalendarStore((s: CalendarState) => s.setPointCode); + const setLocationId = useCalendarStore((s: CalendarState) => s.setLocationId); const onDateChangeRef = useRef(onDateChange); useEffect(() => { onDateChangeRef.current = onDateChange; }, [onDateChange]); - const onMasterPhoneChangeRef = useRef(onMasterPhoneChange); + const onEmployeeIdChangeRef = useRef(onEmployeeIdChange); useEffect(() => { - onMasterPhoneChangeRef.current = onMasterPhoneChange; - }, [onMasterPhoneChange]); + onEmployeeIdChangeRef.current = onEmployeeIdChange; + }, [onEmployeeIdChange]); - // Сохраняем предыдущие значения для сравнения мастеров и событий + // Сравнение мастеров и событий по id const prevMastersLengthRef = useRef(masters.length); const prevEventsLengthRef = useRef(events.length); - const prevMastersPhonesRef = useRef( - masters.map(master => master.phone).join(",") + const prevMastersIdsRef = useRef( + masters.map(master => master.id).join(",") ); const prevEventsIdsRef = useRef(events.map(e => e.id).join(",")); useEffect(() => { - // Обновляем masters только если массив действительно изменился - const currentMastersPhones = masters.map(master => master.phone).join(","); + const currentMastersIds = masters.map(master => master.id).join(","); if ( prevMastersLengthRef.current !== masters.length || - prevMastersPhonesRef.current !== currentMastersPhones + prevMastersIdsRef.current !== currentMastersIds ) { setMasters(masters); prevMastersLengthRef.current = masters.length; - prevMastersPhonesRef.current = currentMastersPhones; + prevMastersIdsRef.current = currentMastersIds; } - // Обновляем events только если массив действительно изменился const currentEventsIds = events.map(e => e.id).join(","); if ( prevEventsLengthRef.current !== events.length || @@ -116,43 +114,40 @@ export function CalendarProvider({ if (initialDate) setSelectedDate(initialDate); }, [masters, events, initialDate]); - // Загружаем рабочие часы точки при изменении point_code - // Используем selectedPointCode если он передан (для net_manager/self_owner), - // иначе используем currentUser.point_code (для других ролей) - const pointCodeToUse = selectedPointCode || currentUser?.point_code; - const prevPointCodeRef = useRef(pointCodeToUse); + // Загружаем рабочие часы локации при изменении location_id + const locationIdToUse = selectedLocationId || currentUser?.location_id; + const prevLocationIdRef = useRef(locationIdToUse); useEffect(() => { - setPointCode(pointCodeToUse ?? undefined); - }, [pointCodeToUse, setPointCode]); + setLocationId(locationIdToUse ?? undefined); + }, [locationIdToUse, setLocationId]); useEffect(() => { - if (pointCodeToUse && prevPointCodeRef.current !== pointCodeToUse) { - loadWorkingHours(pointCodeToUse); - prevPointCodeRef.current = pointCodeToUse; + if (locationIdToUse && prevLocationIdRef.current !== locationIdToUse) { + loadWorkingHours(locationIdToUse); + prevLocationIdRef.current = locationIdToUse; } - }, [pointCodeToUse, loadWorkingHours]); + }, [locationIdToUse, loadWorkingHours]); - // Отслеживаем изменения selectedMasterPhone и вызываем колбэк (только если значение изменилось) - const prevSelectedMasterPhoneRef = useRef( - selectedMasterPhone + // Отслеживаем изменения selectedEmployeeId и вызываем колбэк + const prevSelectedEmployeeIdRef = useRef( + selectedEmployeeId ); - const prevMasterPhoneRef = useRef( - selectedMasterPhone !== "all" ? selectedMasterPhone : undefined + const prevEmployeeIdRef = useRef( + selectedEmployeeId !== "all" ? selectedEmployeeId : undefined ); useEffect(() => { - if (prevSelectedMasterPhoneRef.current !== selectedMasterPhone) { - const masterPhone = - selectedMasterPhone !== "all" ? selectedMasterPhone : undefined; - - // Вызываем колбэк только если masterPhone действительно изменился - if (prevMasterPhoneRef.current !== masterPhone) { - onMasterPhoneChangeRef.current?.(masterPhone); - prevMasterPhoneRef.current = masterPhone; + if (prevSelectedEmployeeIdRef.current !== selectedEmployeeId) { + const empId = + selectedEmployeeId !== "all" ? selectedEmployeeId : undefined; + + if (prevEmployeeIdRef.current !== empId) { + onEmployeeIdChangeRef.current?.(empId); + prevEmployeeIdRef.current = empId; } - prevSelectedMasterPhoneRef.current = selectedMasterPhone; + prevSelectedEmployeeIdRef.current = selectedEmployeeId; } - }, [selectedMasterPhone]); + }, [selectedEmployeeId]); const didInitRef = useRef(false); const prevSelectedDateRef = useRef(null); @@ -164,7 +159,6 @@ export function CalendarProvider({ return; } - // Вызываем onDateChange только если дата действительно изменилась if ( selectedDateValue && (!prevSelectedDateRef.current || diff --git a/src/features/calendar/calendar-context/store.ts b/src/features/calendar/calendar-context/store.ts index 70adf72..8a77b97 100644 --- a/src/features/calendar/calendar-context/store.ts +++ b/src/features/calendar/calendar-context/store.ts @@ -8,8 +8,8 @@ import type { TVisibleHours, TWorkingHours, } from "@/src/shared/types/calendar"; -import { IUserDto } from "@/src/shared/types/user"; -import { PointService } from "@/src/shared/services/point-service"; +import { IEmployeeDto } from "@/src/shared/types/user"; +import { LocationService } from "@/src/shared/services/location-service"; import { parseScheduleTime } from "@/src/shared/utils/datetime"; const WORKING_HOURS: TWorkingHours = { @@ -42,7 +42,7 @@ function emptyWorkingHours(): TWorkingHours { } /** Маппинг ISchedule[] (open/close) в TWorkingHours. Экспорт для calendar-settings. */ -export function mapPointScheduleToWorkingHours( +export function mapLocationScheduleToWorkingHours( schedule: Array<{ all_day: boolean; open: string; @@ -87,31 +87,31 @@ function deriveVisibleHoursFromWorkingHours( export type CalendarState = { selectedDate: Date; setSelectedDate: (date: Date | undefined) => void; - selectedMasterPhone: IUserDto["phone"] | "all"; - setSelectedMasterPhone: (masterPhone: IUserDto["phone"] | "all") => void; + selectedEmployeeId: IEmployeeDto["id"] | "all"; + setSelectedEmployeeId: (employeeId: IEmployeeDto["id"] | "all") => void; badgeVariant: TBadgeVariant; setBadgeVariant: (variant: TBadgeVariant) => void; - masters: IUserDto[]; - setMasters: (masters: IUserDto[]) => void; + masters: IEmployeeDto[]; + setMasters: (masters: IEmployeeDto[]) => void; workingHours: TWorkingHours; setWorkingHours: ( updater: TWorkingHours | ((prev: TWorkingHours) => TWorkingHours) ) => void; - loadWorkingHours: (pointCode: string | undefined) => Promise; + loadWorkingHours: (locationId: string | undefined) => Promise; visibleHours: TVisibleHours; setVisibleHours: ( updater: TVisibleHours | ((prev: TVisibleHours) => TVisibleHours) ) => void; /** * Если true — visibleHours ещё не задавались пользователем, - * и их можно автоподстроить под workingHours точки. + * и их можно автоподстроить под workingHours локации. */ isVisibleHoursAuto: boolean; events: IEvent[]; setLocalEvents: (updater: IEvent[] | ((prev: IEvent[]) => IEvent[])) => void; - /** Код точки: selectedPointCode || currentUser.point_code. Для выборов услуги в форме записи. */ - pointCode: string | undefined; - setPointCode: (v: string | undefined) => void; + /** UUID локации: selectedLocationId || currentUser.location_id. Для выбора услуги в форме записи. */ + locationId: string | undefined; + setLocationId: (v: string | undefined) => void; }; export const useCalendarStore = create()( @@ -124,14 +124,14 @@ export const useCalendarStore = create()( if (isSameDay(current, date)) return; set({ selectedDate: date }); }, - selectedMasterPhone: "all", - setSelectedMasterPhone: (masterPhone: IUserDto["phone"] | "all") => - set({ selectedMasterPhone: masterPhone }), + selectedEmployeeId: "all", + setSelectedEmployeeId: (employeeId: IEmployeeDto["id"] | "all") => + set({ selectedEmployeeId: employeeId }), badgeVariant: "colored", setBadgeVariant: (variant: TBadgeVariant) => set({ badgeVariant: variant }), masters: [], - setMasters: (masters: IUserDto[]) => set({ masters }), + setMasters: (masters: IEmployeeDto[]) => set({ masters }), workingHours: WORKING_HOURS, setWorkingHours: ( updater: TWorkingHours | ((prev: TWorkingHours) => TWorkingHours) @@ -144,19 +144,16 @@ export const useCalendarStore = create()( ) : updater, })), - loadWorkingHours: async (pointCode: string | undefined) => { - if (!pointCode) return; - const point = await PointService.getPoint(pointCode); - const workingHours = mapPointScheduleToWorkingHours(point.schedule); - const nextVisibleHours = - deriveVisibleHoursFromWorkingHours(workingHours); - - set(state => ({ - workingHours, - ...(state.isVisibleHoursAuto && nextVisibleHours - ? { visibleHours: nextVisibleHours } - : {}), - })); + loadWorkingHours: async (locationId: string | undefined) => { + if (!locationId) return; + try { + const location = await LocationService.getLocation(locationId); + // TODO: schedule данные пока не приходят из GET /api/v1/locations/{id} + // Когда бэк добавит schedule — парсить и маппить как раньше + void location; + } catch { + // Используем дефолтные рабочие часы при ошибке + } }, visibleHours: VISIBLE_HOURS, setVisibleHours: ( @@ -180,8 +177,8 @@ export const useCalendarStore = create()( ? (updater as (prev: IEvent[]) => IEvent[])(state.events) : updater, })), - pointCode: undefined, - setPointCode: (v: string | undefined) => set({ pointCode: v }), + locationId: undefined, + setLocationId: (v: string | undefined) => set({ locationId: v }), }), { name: "calendar-store", diff --git a/src/features/calendar/dialogs/edit-event-dialog.tsx b/src/features/calendar/dialogs/edit-event-dialog.tsx index 76a2639..518da8c 100644 --- a/src/features/calendar/dialogs/edit-event-dialog.tsx +++ b/src/features/calendar/dialogs/edit-event-dialog.tsx @@ -60,7 +60,7 @@ export function EditEventDialog({ children, event }: IProps) { const form = useForm({ resolver: zodResolver(eventSchema), defaultValues: { - user: event.masterPhone, + user: event.employeeId, title: event.title, comment: event.comment, startDate: parseTimestamp(event.startDate), @@ -78,7 +78,7 @@ export function EditEventDialog({ children, event }: IProps) { }); const onSubmit = (values: TEventFormData) => { - const master = masters.find(master => master.phone === values.user); + const master = masters.find(m => m.id === values.user); if (!master) throw new Error("Master not found"); @@ -90,7 +90,7 @@ export function EditEventDialog({ children, event }: IProps) { updateEvent({ ...event, - masterPhone: master.phone, + employeeId: master.id, title: values.title, color: values.color, comment: values.comment ?? "", @@ -135,23 +135,23 @@ export function EditEventDialog({ children, event }: IProps) { {masters.map(master => (
- + - {`${master.name[0]}${master.surname[0]}`} + {`${master.first_name[0]}${master.last_name[0]}`}

- {master.name} {master.surname} + {master.first_name} {master.last_name}

diff --git a/src/features/calendar/dialogs/event-details-dialog.tsx b/src/features/calendar/dialogs/event-details-dialog.tsx index f9679da..dd7a2ca 100644 --- a/src/features/calendar/dialogs/event-details-dialog.tsx +++ b/src/features/calendar/dialogs/event-details-dialog.tsx @@ -1,11 +1,14 @@ "use client"; +import { useState } from "react"; import { format, parseISO } from "date-fns"; -import { Calendar, Clock, Text, User } from "lucide-react"; +import { Calendar, Clock, Phone, Text, User, XCircle } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/src/entities/button"; -import { EditEventDialog } from "./edit-event-dialog"; +import { Input } from "@/src/entities/input"; import { useCalendar } from "@/src/features/calendar"; +import { useCancelBooking } from "@/src/shared/hooks/use-bagsies"; import { Dialog, DialogContent, @@ -27,71 +30,165 @@ export function EventDetailsDialog({ event, children }: IProps) { const { masters } = useCalendar(); const startDate = parseISO(event.startDate); const endDate = parseISO(event.endDate); - const master = masters.find(m => m.phone === event.masterPhone) ?? null; + const master = masters.find(m => m.id === event.employeeId) ?? null; const t = useTranslations("Dashboard.Calendar.EventDetailsDialog"); + + // Состояние для отмены записи + const [showCancelConfirm, setShowCancelConfirm] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + const cancelBooking = useCancelBooking(); + + const handleCancel = () => { + cancelBooking.mutate( + { id: event.id, reason: cancelReason || undefined }, + { + onSuccess: () => { + toast.success(t("cancelSuccess")); + setShowCancelConfirm(false); + setCancelReason(""); + }, + onError: () => { + toast.error(t("cancelError")); + }, + } + ); + }; + + // Запись уже отменена — не показываем кнопку отмены + const isCancelled = event.status === "cancelled"; + return ( - <> - - {children} + + {children} - - - {event.title} - + + + {event.title} + -
-
- -
-

{t("responsible")}

-

- {`${master?.name} ${master?.surname}` || event.masterPhone} -

-
+
+ {/* Клиент */} +
+ +
+

{t("client")}

+

+ {event.customerName} +

+
+ {/* Телефон клиента */} + {event.customerPhone && (
- +
-

{t("startDate")}

- {format(startDate, "MMM d, yyyy HH:mm")} + {event.customerPhone}

+ )} + + {/* Ответственный мастер */} +
+ +
+

{t("responsible")}

+

+ {master + ? `${master.first_name} ${master.last_name}` + : event.employeeName} +

+
+
+ + {/* Дата начала */} +
+ +
+

{t("startDate")}

+

+ {format(startDate, "MMM d, yyyy HH:mm")} +

+
+
+ {/* Дата окончания */} +
+ +
+

{t("endDate")}

+

+ {format(endDate, "MMM d, yyyy HH:mm")} +

+
+
+ + {/* Комментарий */} + {event.comment && (
- +
-

{t("endDate")}

+

{t("comment")}

- {format(endDate, "MMM d, yyyy HH:mm")} + {event.comment}

+ )} - {event.comment && ( -
- -
-

{t("comment")}

-

- {event.comment} -

-
+ {/* Форма подтверждения отмены */} + {showCancelConfirm && ( +
+

{t("cancelConfirm")}

+ setCancelReason(e.target.value)} + disabled={cancelBooking.isPending} + /> +
+ +
- )} -
- {/* +
+ )} +
+ + {/* Кнопка отмены записи */} + {!isCancelled && !showCancelConfirm && ( - - - - */} - -
- + + + )} + +
); } diff --git a/src/features/calendar/event-dialogs/add-event-dialog.tsx b/src/features/calendar/event-dialogs/add-event-dialog.tsx index dbdf8b2..505bc69 100644 --- a/src/features/calendar/event-dialogs/add-event-dialog.tsx +++ b/src/features/calendar/event-dialogs/add-event-dialog.tsx @@ -5,8 +5,8 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; -import { useDisclosure, useCreateBagsie } from "@/src/shared/hooks"; -import { usePointServices } from "@/src/shared/hooks/use-services"; +import { useDisclosure, useCreateBooking } from "@/src/shared/hooks"; +import { useLocationServices } from "@/src/shared/hooks/use-services"; import { useCurrentUser } from "@/src/shared/hooks/use-users"; import { useCalendar } from "@/src/features/calendar/calendar-context"; import { toTimestampWithTz } from "@/src/shared/utils/formater"; @@ -44,7 +44,7 @@ import { DialogFooter, } from "@/src/entities/dialog"; -import { addBagsieSchema, type TAddBagsieFormData } from "@/src/shared/schemas"; +import { addBookingSchema, type TAddBookingFormData } from "@/src/shared/schemas"; import { useTranslations } from "next-intl"; import type { TimeValue } from "react-aria-components"; @@ -57,51 +57,56 @@ interface IProps { } export function AddEventDialog({ children, startDate, startTime }: IProps) { - const { masters, pointCode } = useCalendar(); + const { masters, locationId } = useCalendar(); const { data: currentUser } = useCurrentUser(); const { data: servicesData, isLoading: isLoadingServices } = - usePointServices(pointCode); + useLocationServices(locationId); const t = useTranslations("Dashboard.Calendar.AddEventDialog"); - const createBagsie = useCreateBagsie(); + const createBooking = useCreateBooking(); const { isOpen, onClose, onToggle } = useDisclosure(); const isStaff = currentUser?.role === EUserRole.STAFF; - // manager и выше: выбор мастера из /staff; STAFF: только свой номер (поле скрыто) + // manager и выше: выбор мастера из employees; для STAFF — только свой id (поле скрыто) const showMasterSelect = !isStaff && masters.length > 0; - const form = useForm({ - resolver: zodResolver(addBagsieSchema), + const form = useForm({ + resolver: zodResolver(addBookingSchema), defaultValues: { - name: "", - surname: "", - client_phone: "", + first_name: "", + last_name: "", + phone: "", comment: "", - master_phone: showMasterSelect ? "" : undefined, + employee_id: showMasterSelect ? "" : undefined, service_id: "", startDate: startDate ?? new Date(), startTime: startTime ?? { hour: 10, minute: 0 }, }, }); - const onSubmit = async (values: TAddBagsieFormData) => { - const masterPhone = isStaff ? currentUser?.phone : values.master_phone; - if (!masterPhone) { + const onSubmit = async (values: TAddBookingFormData) => { + const employeeId = isStaff ? currentUser?.id : values.employee_id; + if (!employeeId) { toast.error(t("staffDescription") ?? "Выберите мастера"); return; } + if (!locationId) { + toast.error("Выберите точку"); + return; + } const d = new Date(values.startDate); d.setHours(values.startTime.hour, values.startTime.minute, 0, 0); const start_at = toTimestampWithTz(d); try { - await createBagsie.mutateAsync({ - client_phone: values.client_phone, + await createBooking.mutateAsync({ + phone: values.phone, + first_name: values.first_name, + last_name: values.last_name, comment: values.comment ?? "", - master_phone: masterPhone, - name: values.name, + employee_id: employeeId, + location_id: locationId, service_id: values.service_id, start_at, - surname: values.surname, }); onClose(); form.reset(); @@ -131,11 +136,11 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { onSubmit={form.handleSubmit(onSubmit)} className="grid sm:grid-cols-2 gap-4 py-4 px-1 max-h-[400px] md:max-h-none overflow-y-auto" > - {/* Мастер: для manager+ — выбор из /staff; для STAFF — только свой номер (readonly/скрыто) */} + {/* Мастер: для manager+ — выбор из employees; для STAFF — только свой id (скрыто) */} {showMasterSelect && ( ( {t("staff")} @@ -150,22 +155,22 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { {masters.map(master => (
- {`${master.name[0]}${master.surname[0]}`} + {`${master.first_name[0]}${master.last_name[0]}`}

- {master.name} {master.surname} + {master.first_name} {master.last_name}

@@ -179,7 +184,7 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { /> )} - {/* Услуга: select из /services по pointCode */} + {/* Услуга: select из /services по locationId */} ( {t("firstName")} @@ -241,7 +246,7 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { /> ( {t("lastName")} @@ -258,7 +263,7 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { /> ( {t("phone")} diff --git a/src/features/calendar/settings/calendar-settings.tsx b/src/features/calendar/settings/calendar-settings.tsx index adecdd9..e654b6b 100644 --- a/src/features/calendar/settings/calendar-settings.tsx +++ b/src/features/calendar/settings/calendar-settings.tsx @@ -14,7 +14,7 @@ import { Save, Settings } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/src/entities/button"; import { useCalendar } from "@/src/features/calendar/calendar-context"; -import { mapPointScheduleToWorkingHours } from "@/src/features/calendar/calendar-context/store"; +import { mapLocationScheduleToWorkingHours } from "@/src/features/calendar/calendar-context/store"; import { useIsMobile } from "@/src/shared/hooks/use-mobile"; import { useCurrentUser, @@ -26,7 +26,7 @@ import { } from "@/src/shared/utils/formater"; import { ChangeBadgeVariantInput } from "./change-badge-variant-input"; import { ChangeVisibleHoursInput } from "./change-visible-hours-input"; -import { ScheduleEditor } from "@/src/features/points/components/schedule-editor"; +import { ScheduleEditor } from "@/src/features/locations/components/schedule-editor"; import type { TBadgeVariant, TVisibleHours } from "@/src/shared/types/calendar"; import type { ISchedule } from "@/src/shared/types/user"; import { toast } from "sonner"; @@ -49,9 +49,10 @@ export function CalendarSettings() { useState(null); // При открытии диалога — подставляем расписание пользователя + // TODO: schedule пока не приходит из GET /api/v1/employees/me, ждём эндпоинт useEffect(() => { - if (isOpen) setTempSchedule(currentUser?.schedule ?? []); - }, [isOpen, currentUser?.schedule]); + if (isOpen) setTempSchedule([]); + }, [isOpen]); // Обработчик сохранения: расписание в API, badge/visible — в контекст const handleSave = useCallback(async () => { @@ -60,7 +61,8 @@ export function CalendarSettings() { if (tempVisibleHours) setVisibleHours(tempVisibleHours); // 2. Сохраняем расписание в API - const toSave = tempSchedule ?? currentUser?.schedule ?? []; + // TODO: schedule пока не приходит из employees/me, используем tempSchedule + const toSave = tempSchedule ?? []; const isValid = toSave.length >= 1 && toSave.some(s => s.all_day || (s.open && s.close)); if (!isValid) { @@ -91,7 +93,7 @@ export function CalendarSettings() { try { await updateScheduleMutation.mutateAsync({ schedule }); - setWorkingHours(mapPointScheduleToWorkingHours(toSave)); + setWorkingHours(mapLocationScheduleToWorkingHours(toSave)); toast.success(t("scheduleSaved")); setIsOpen(false); setTempSchedule(null); @@ -104,7 +106,6 @@ export function CalendarSettings() { tempBadgeVariant, tempVisibleHours, tempSchedule, - currentUser?.schedule, t, setBadgeVariant, setVisibleHours, @@ -154,7 +155,7 @@ export function CalendarSettings() { {/* Содержимое: расписание (API) + badge + видимые часы */}
setTempSchedule(s)} isMobile={isMobile} title={t("myScheduleTitle")} diff --git a/src/features/dashboard/dashboard-page.tsx b/src/features/dashboard/dashboard-page.tsx index e15041c..1d34ad3 100644 --- a/src/features/dashboard/dashboard-page.tsx +++ b/src/features/dashboard/dashboard-page.tsx @@ -7,11 +7,11 @@ import { DashboardHeader, DashboardContent } from "@/src/features"; import { Loader } from "lucide-react"; import { useCalendar as useCalendarApi } from "@/src/shared/hooks/use-calendar"; import { useCurrentUser } from "@/src/shared/hooks/use-users"; -import { useNetworkPoints } from "@/src/shared/hooks/use-network-points"; +import { useLocations } from "@/src/shared/hooks/use-network-locations"; import { EUserRole } from "@/src/shared/types/user"; -import { EmptyPointsState } from "@/src/features/dashboard/empty-points-state"; +import { EmptyLocationsState } from "@/src/features/dashboard/empty-locations-state"; import { toast } from "sonner"; -import EmptyPointsHeader from "./empty-points-header"; +import EmptyLocationsHeader from "./empty-locations-header"; export function DashboardPage() { const searchParams = useSearchParams(); @@ -81,50 +81,45 @@ export function DashboardPage() { // Получаем текущего пользователя const { data: currentUser } = useCurrentUser(); - // Определяем, нужно ли загружать точки (только для net_manager и self_owner) - const shouldLoadPoints = useMemo(() => { - return ( - currentUser && - (currentUser.role === EUserRole.NET_MANAGER || - currentUser.role === EUserRole.SELF_OWNER) - ); + // Определяем, нужно ли загружать локации (только для Owner) + const shouldLoadLocations = useMemo(() => { + return currentUser && currentUser.role === EUserRole.OWNER; }, [currentUser]); - // Загружаем точки сети для net_manager и self_owner + // Загружаем локации организации для Owner const { - data: networkPointsData, - isLoading: isLoadingPoints, - isError: isPointsError, - error: pointsError, - } = useNetworkPoints(currentUser?.network_code); - - // Автоматически выбираем первую точку из списка при загрузке - const selectedPointCode = useMemo(() => { - // Для ролей с выбором точки - используем первую из списка - if (shouldLoadPoints && networkPointsData) { - const points = networkPointsData.points; - if (points && points.length > 0) { - return points[0].code; + data: locationsData, + isLoading: isLoadingLocations, + isError: isLocationsError, + error: locationsError, + } = useLocations(); + + // Автоматически выбираем первую локацию из списка при загрузке + const selectedLocationId = useMemo(() => { + if (shouldLoadLocations && locationsData) { + const locations = locationsData.locations; + if (locations && locations.length > 0) { + return locations[0].id; } } - // Для других ролей - используем point_code из currentUser - return currentUser?.point_code; - }, [shouldLoadPoints, networkPointsData, currentUser?.point_code]); + // Для других ролей - используем location_id из currentUser + return currentUser?.location_id; + }, [shouldLoadLocations, locationsData, currentUser?.location_id]); // Получаем начальную дату const [selectedDate, setSelectedDate] = useState(() => getInitialDate() ); - // Состояние для masterPhone (будет обновляться через CalendarProvider при изменении selectedMasterPhone) - const [masterPhone, setMasterPhone] = useState(undefined); + // Состояние для employeeId (будет обновляться через CalendarProvider при изменении selectedEmployeeId) + const [employeeId, setEmployeeId] = useState(undefined); - // Обертка для setMasterPhone, которая обновляет состояние только если значение изменилось - const handleMasterPhoneChange = React.useCallback( - (newMasterPhone: string | undefined) => { - setMasterPhone(prev => { - if (prev !== newMasterPhone) { - return newMasterPhone; + // Обертка для setEmployeeId, которая обновляет состояние только если значение изменилось + const handleEmployeeIdChange = React.useCallback( + (newEmployeeId: string | undefined) => { + setEmployeeId(prev => { + if (prev !== newEmployeeId) { + return newEmployeeId; } return prev; }); @@ -159,12 +154,12 @@ export function DashboardPage() { }, [searchParams]); // Загружаем данные календаря через API - // Передаем selectedPointCode для net_manager и self_owner + // Передаем selectedLocationId для net_manager и self_owner const { events, masters, isError, error } = useCalendarApi({ selectedDate, view: calendarView, - pointCode: selectedPointCode, - masterPhone, + locationId: selectedLocationId, + employeeId, }); // Обработка ошибок загрузки @@ -180,28 +175,28 @@ export function DashboardPage() { // Обработка ошибок загрузки точек useEffect(() => { - if (isPointsError && pointsError) { + if (isLocationsError && locationsError) { const errorMessage = - pointsError instanceof Error - ? pointsError.message + locationsError instanceof Error + ? locationsError.message : "Ошибка загрузки точек сети"; toast.error(errorMessage); } - }, [isPointsError, pointsError]); + }, [isLocationsError, locationsError]); - // Если точек нет (пустой массив) - показываем EmptyPointsState - const hasNoPoints = - shouldLoadPoints && - !isLoadingPoints && - networkPointsData && - networkPointsData.points.length === 0; + // Если точек нет (пустой массив) - показываем EmptyLocationsState + const hasNoLocations = + shouldLoadLocations && + !isLoadingLocations && + locationsData && + locationsData.locations.length === 0; - // Для NET_MANAGER и SELF_OWNER точка обязательна - не показываем календарь без точки + // Для NET_MANAGER и SELF_OWNER точка обязательна - не показываем календарь без локации const shouldShowCalendar = - !shouldLoadPoints || (shouldLoadPoints && selectedPointCode); + !shouldLoadLocations || (shouldLoadLocations && selectedLocationId); - // Показываем загрузку пока монтируется компонент или загружаются точки - if (!isMounted || (shouldLoadPoints && isLoadingPoints)) { + // Показываем загрузку пока монтируется компонент или загружаются локации + if (!isMounted || (shouldLoadLocations && isLoadingLocations)) { return (
@@ -209,12 +204,12 @@ export function DashboardPage() { ); } - // Если точек нет - показываем EmptyPointsState - if (hasNoPoints) { + // Если точек нет - показываем EmptyLocationsState + if (hasNoLocations) { return ( <> - - + + ); } @@ -233,7 +228,7 @@ export function DashboardPage() { events={events} masters={masters} initialDate={selectedDate} - selectedPointCode={selectedPointCode} + selectedLocationId={selectedLocationId} onDateChange={date => { // Проверяем, изменилась ли дата перед обновлением if (format(date, "yyyy-MM-dd") !== format(selectedDate, "yyyy-MM-dd")) { @@ -241,7 +236,7 @@ export function DashboardPage() { setSelectedDate(date); } }} - onMasterPhoneChange={handleMasterPhoneChange} + onEmployeeIdChange={handleEmployeeIdChange} > diff --git a/src/features/dashboard/empty-points-header.tsx b/src/features/dashboard/empty-locations-header.tsx similarity index 82% rename from src/features/dashboard/empty-points-header.tsx rename to src/features/dashboard/empty-locations-header.tsx index 53513a0..6d0c616 100644 --- a/src/features/dashboard/empty-points-header.tsx +++ b/src/features/dashboard/empty-locations-header.tsx @@ -3,8 +3,8 @@ import { SidebarTrigger } from "@/src/entities/sidebar"; import { Separator } from "@/src/entities/separator"; import { useTranslations } from "next-intl"; -export const EmptyPointsHeader: React.FC = () => { - const t = useTranslations("Dashboard.emptyPoints"); +export const EmptyLocationsHeader: React.FC = () => { + const t = useTranslations("Dashboard.emptyLocations"); return (
@@ -16,4 +16,4 @@ export const EmptyPointsHeader: React.FC = () => { ); }; -export default EmptyPointsHeader; +export default EmptyLocationsHeader; diff --git a/src/features/dashboard/empty-points-state.tsx b/src/features/dashboard/empty-locations-state.tsx similarity index 90% rename from src/features/dashboard/empty-points-state.tsx rename to src/features/dashboard/empty-locations-state.tsx index 303278f..b3d7e73 100644 --- a/src/features/dashboard/empty-points-state.tsx +++ b/src/features/dashboard/empty-locations-state.tsx @@ -16,8 +16,8 @@ import { useRouter } from "next/navigation"; * Компонент для отображения состояния отсутствия точек обслуживания * Показывается когда у пользователя нет точек обслуживания */ -export function EmptyPointsState() { - const t = useTranslations("Dashboard.emptyPoints"); +export function EmptyLocationsState() { + const t = useTranslations("Dashboard.emptyLocations"); const router = useRouter(); return (
@@ -41,7 +41,7 @@ export function EmptyPointsState() {
- diff --git a/src/features/dashboard/index.ts b/src/features/dashboard/index.ts index 0e55051..889575c 100644 --- a/src/features/dashboard/index.ts +++ b/src/features/dashboard/index.ts @@ -5,4 +5,4 @@ export { DashboardContent } from "./dashboard-content"; export { DashboardHeader } from "./dashboard-header"; -export { EmptyPointsState } from "./empty-points-state"; +export { EmptyLocationsState } from "./empty-locations-state"; diff --git a/src/features/points/components/add-point-dialog.tsx b/src/features/locations/components/add-location-dialog.tsx similarity index 77% rename from src/features/points/components/add-point-dialog.tsx rename to src/features/locations/components/add-location-dialog.tsx index c854608..95ffe51 100644 --- a/src/features/points/components/add-point-dialog.tsx +++ b/src/features/locations/components/add-location-dialog.tsx @@ -9,7 +9,7 @@ import { DialogTitle, } from "@/src/entities/dialog"; import { useTranslations } from "next-intl"; -import { AddPointForm } from "./add-point-form"; +import { AddLocationForm } from "./add-location-form"; interface AddPointDialogProps { open: boolean; @@ -17,11 +17,11 @@ interface AddPointDialogProps { } /** - * Диалог для добавления новой точки обслуживания + * Диалог для добавления новой локации обслуживания * Содержит форму с валидацией и компонентом редактирования расписания */ export function AddPointDialog({ open, onOpenChange }: AddPointDialogProps) { - const t = useTranslations("Points.addPointDialog"); + const t = useTranslations("Locations.addPointDialog"); const handleSuccess = () => { onOpenChange(false); @@ -38,7 +38,7 @@ export function AddPointDialog({ open, onOpenChange }: AddPointDialogProps) { {t("title")} {t("description")} - + ); diff --git a/src/features/locations/components/add-location-form.tsx b/src/features/locations/components/add-location-form.tsx new file mode 100644 index 0000000..69e75ab --- /dev/null +++ b/src/features/locations/components/add-location-form.tsx @@ -0,0 +1,524 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useTranslations } from "next-intl"; +import { Button } from "@/src/entities/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/src/entities/form"; +import { Input } from "@/src/entities/input"; +import { Textarea } from "@/src/entities/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/src/entities/select"; +import { Skeleton } from "@/src/entities/skeleton"; +import { Loader } from "lucide-react"; +import { toast } from "sonner"; +import { useCurrentUser } from "@/src/shared/hooks/use-users"; +import { + useLocationCategories, + useCreateLocation, +} from "@/src/shared/hooks/use-network-locations"; +import { AddressSearch } from "./address-search"; +import dynamic from "next/dynamic"; +import type { INominatimResult } from "@/src/shared/services/nominatim-service"; +import { PhoneInput } from "@/src/widgets"; + +// Динамический импорт карты с отключением SSR +const AddressMap = dynamic( + () => import("./address-map").then(mod => ({ default: mod.AddressMap })), + { + ssr: false, + loading: () => ( +
+

Загрузка карты...

+
+ ), + } +); + +/** + * Типы расписания: + * - mixed — у локации своё расписание, у мастеров своё + * - fixed — все мастера работают по расписанию локации + * Для SOLO плана всегда fixed (мастер = владелец, расписания синхронизируются на беке) + */ +const SCHEDULE_TYPES = ["mixed", "fixed"] as const; + +/** Варианты длительности слота (минуты) — промежутки для записи */ +const SLOT_DURATIONS = [5, 10, 15, 30, 60] as const; + +/** + * Схема валидации для создания локации (POST /api/v1/locations) + */ +const createAddLocationSchema = (t: (key: string) => string) => + z + .object({ + name: z + .string() + .min(2, t("errors.nameMin")) + .max(100, t("errors.nameMax")), + description: z + .string() + .max(500, t("errors.descriptionMax")) + .optional() + .or(z.literal("")), + phone: z.string().min(1, t("errors.phoneRequired")), + category_id: z.string().min(1, t("errors.categoryIdRequired")), + schedule_type: z.string().min(1, t("errors.scheduleTypeRequired")), + slot_duration_minutes: z + .number() + .int() + .min(5, t("errors.slotDurationMin")) + .max(480, t("errors.slotDurationMax")), + address: z.object({ + city: z + .string(t("errors.cityMin")) + .min(2, t("errors.cityMin")) + .max(100, t("errors.cityMax")), + street: z + .string(t("errors.streetMin")) + .min(2, t("errors.streetMin")) + .max(200, t("errors.streetMax")), + building: z.string().optional().or(z.literal("")), + details: z.string().optional().or(z.literal("")), + }), + // Координаты из поиска адреса + latitude: z.number(), + longitude: z.number(), + }) + .refine(data => data.latitude !== 0 || data.longitude !== 0, { + message: t("errors.addressRequired"), + path: ["latitude"], + }); + +type AddLocationFormData = z.infer>; + +interface AddPointFormProps { + onSuccess?: () => void; + onCancel?: () => void; +} + +/** + * Форма для добавления новой локации обслуживания + * Поля: название, описание, телефон, категория, тип расписания, + * длительность слота, адрес (поиск + карта), здание, доп. инфо + */ +export function AddLocationForm({ onSuccess, onCancel }: AddPointFormProps) { + const t = useTranslations("Locations.addPointForm"); + const { data: currentUser } = useCurrentUser(); + const { data: categoriesData, isLoading: isLoadingCategories } = + useLocationCategories(); + const createLocationMutation = useCreateLocation(); + + // TODO: когда бэк отдаст plan_code в employees/me или organizations/me, + // заменить на реальную проверку (currentUser.plan_code === "solo") + // SOLO: владелец = единственный мастер, расписания синхронизируются на беке → всегда fixed + const isSoloPlan = currentUser?.permissions?.can_provide_services === true; + + const schema = createAddLocationSchema(t); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + description: "", + phone: "", + category_id: "", + schedule_type: isSoloPlan ? "fixed" : "mixed", + slot_duration_minutes: 30, + address: { + city: "", + street: "", + building: "", + details: "", + }, + latitude: 0, + longitude: 0, + }, + }); + + const onSubmit = async (data: AddLocationFormData) => { + if (!currentUser?.organization_id) { + toast.error(t("errors.networkCodeRequired")); + return; + } + + try { + await createLocationMutation.mutateAsync({ + name: data.name, + description: data.description || undefined, + phone: data.phone, + category_id: data.category_id, + latitude: data.latitude, + longitude: data.longitude, + schedule_type: data.schedule_type, + slot_duration_minutes: data.slot_duration_minutes, + address: { + city: data.address.city, + street: data.address.street, + building: data.address.building || "", + details: data.address.details || undefined, + }, + }); + + toast.success(t("success")); + form.reset(); + onSuccess?.(); + } catch (error) { + console.error("Ошибка создания локации:", error); + toast.error(t("errors.submitError")); + } + }; + + const isPending = createLocationMutation.isPending; + + return ( +
+ + {/* Основная информация */} +
+ {/* Название */} + ( + + {t("name")} + + + + + + )} + /> + + {/* Телефон */} + ( + + {t("phone")} + + + + + + )} + /> +
+ +
+ {/* Категория */} + ( + + {t("categoryId")} + + {isLoadingCategories ? ( + + ) : ( + + )} + + + + )} + /> + + {/* Тип расписания — для SOLO всегда fixed, селект скрыт */} + ( + + {t("scheduleType")} + {isSoloPlan ? ( + /* SOLO: тип зафиксирован, показываем только инфо */ +
+ {t("scheduleTypes.fixed.label")} — {t("soloScheduleNote")} +
+ ) : ( + + )} + {/* Подсказка под полем */} + {!isSoloPlan && ( +

+ {t(`scheduleTypes.${field.value}.hint`)} +

+ )} + +
+ )} + /> +
+ + {/* Длительность слота */} + ( + + {t("slotDuration")} + +

+ {t("slotDurationHint")} +

+ +
+ )} + /> + + {/* Описание */} + ( + + {t("description")} + +