diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7bae57d..dc77c17 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,5 +1,5 @@ { "permissions": { - "allow": ["mcp__acp__Edit"] + "allow": ["mcp__acp__Edit", "Bash(npx next build)"] } } diff --git a/.gitignore b/.gitignore index 5ef6a52..62287bc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,18 @@ 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 + +certificates \ No newline at end of file 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/[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)/account/page.tsx b/app/[locale]/(sidebar)/account/page.tsx new file mode 100644 index 0000000..2d5f523 --- /dev/null +++ b/app/[locale]/(sidebar)/account/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { AccountPage } from "@/src/features/account"; + +/** + * Страница аккаунта — объединяет профиль, подписку, безопасность, настройки + */ +export default function Page() { + return ; +} diff --git a/app/[locale]/(sidebar)/locations/[id]/page.tsx b/app/[locale]/(sidebar)/locations/[id]/page.tsx new file mode 100644 index 0000000..214630e --- /dev/null +++ b/app/[locale]/(sidebar)/locations/[id]/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { use } from "react"; +import { useTranslations } from "next-intl"; +import { Card, CardContent, Skeleton } from "@/src/entities"; +import { LocationsHeader } from "@/src/features/locations"; +import { LocationDetailView } from "@/src/features/locations/components/location-detail-view"; +import { useLocation } from "@/src/shared/hooks/use-network-locations"; +import { useCurrentUser } from "@/src/shared/hooks/use-users"; +import { EUserRole } from "@/src/shared/types/user"; + +/** + * Скелетон для детального вида локации + */ +function DetailSkeleton() { + return ( +
+ {/* Back button */} + + {/* Header */} +
+
+ + +
+
+ + +
+
+ {/* Info grid */} +
+ + + +
+ {/* Map */} + + {/* Working hours */} +
+ + {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+ {/* Stats */} +
+ + +
+ {/* Booking link */} + +
+ ); +} + +/** + * Страница детального просмотра локации + * Sub-route: /locations/[id] + */ +export default function LocationDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const t = useTranslations("Locations"); + const { data: currentUser, isLoading: userLoading } = useCurrentUser(); + const { data: location, isLoading, error } = useLocation(id); + + // Загрузка — скелетон + if (isLoading || userLoading) { + return ( + <> + + + + ); + } + + // Проверка доступа (Owner / Manager) + const hasAccess = + currentUser && + (currentUser.role === EUserRole.OWNER || + currentUser.role === EUserRole.MANAGER); + + if (!hasAccess) { + return ( + <> + +
+ + +

+ {t("accessDenied")} +

+

+ {t("accessDeniedDescription")} +

+
+
+
+ + ); + } + + // Ошибка загрузки + if (error || !location) { + return ( + <> + +
+ + +

+ {t("errorLoading")} +

+
+
+
+ + ); + } + + return ( + <> + + + + ); +} 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/app/[locale]/(sidebar)/profile/page.tsx b/app/[locale]/(sidebar)/profile/page.tsx deleted file mode 100644 index 6a51d07..0000000 --- a/app/[locale]/(sidebar)/profile/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import { useCurrentUser } from "@/src/shared/hooks"; -import { ProfileHeader } from "@/src/features/profile/profile-header"; -import { ProfileDisplay } from "@/src/features/profile/profile-display"; - -/** - * Страница профиля пользователя - * Отображает информацию о пользователе и позволяет её редактировать - */ -export default function ProfilePage() { - const { data: userData, isLoading } = useCurrentUser(); - - return ( - <> - - -
- {/* Отображение информации о профиле */} - -
- - ); -} 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/app/[locale]/(sidebar)/settings/page.tsx b/app/[locale]/(sidebar)/settings/page.tsx deleted file mode 100644 index 5f8e6c1..0000000 --- a/app/[locale]/(sidebar)/settings/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { SettingsContent } from "@/src/features/settings"; -import { SettingsHeader } from "@/src/features/settings/settings-header"; -import { Loader } from "lucide-react"; -import { Suspense } from "react"; - -export default function SettingsPage() { - return ( - <> - - - }> - - - - ); -} diff --git a/app/[locale]/offline/page.tsx b/app/[locale]/offline/page.tsx new file mode 100644 index 0000000..37ad655 --- /dev/null +++ b/app/[locale]/offline/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +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 ( +
+
+
📡
+

{t("title")}

+

{t("description")}

+ +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index d4deee4..9732223 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..fa5d99a --- /dev/null +++ b/app/sw.ts @@ -0,0 +1,96 @@ +/// + +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: "/:locale/offline", + matcher({ request }) { + return request.destination === "document"; + }, + }, + ], + }, +}); + +// 🔔 Обработка 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/bun.lock b/bun.lock index d7284bc..16213fb 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,8 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.3", "react-hook-form": "^7.63.0", + "react-leaflet": "^5.0.0", + "react-qrcode-logo": "^4.0.0", "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", @@ -57,6 +61,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -68,6 +73,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 +207,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 +249,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 +447,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 +573,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 +655,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 +737,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 +781,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 +805,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 +871,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 +895,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 +965,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 +983,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 +1015,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 +1051,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 +1089,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 +1109,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 +1149,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 +1169,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 +1187,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 +1213,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 +1223,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,10 +1239,14 @@ "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=="], + "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -1196,6 +1267,10 @@ "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-qrcode-logo": ["react-qrcode-logo@4.0.0", "", { "dependencies": { "qrcode-generator": "^2.0.4" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-TcDdsJQe7P0OY7uA7Do4Z0DfIIjjqx81RbBGQY+90T2Ba42pUCx/cSI2UTwPPoH9WwE0StLb8A98mFgKIAI4JQ=="], + "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 +1311,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 +1335,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 +1363,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 +1393,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 +1419,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 +1435,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 +1451,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 +1465,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 +1491,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 +1499,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 +1517,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/claude.md b/claude.md index fc854bf..76373f1 100644 --- a/claude.md +++ b/claude.md @@ -1,14 +1,87 @@ -- **Чем меньше строк кода тем лучше**. -- _Действуй как крутой 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, account) +src/widgets/ — Составные UI (сайдбар, календарь) +src/features/ — Бизнес-логика (auth, calendar, dashboard, staff, locations, services, account) +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` — добавление мастера +- `ACCOUNT_PAGE.md` — страница аккаунта (профиль, подписка, безопасность, внешний вид) +- `SCHEDULE_PAGE.md` — страница расписания (календарь, editor, пресеты, права, мобилка) + +## Важно + +- Регистрация — в другом проекте (лендинг bagsy.kz), в ЛК её НЕ реализуем +- Часть GET-эндпоинтов (`/users/me`, `/employees`, `/locations`, `/services`) скоро появятся — не ломать текущую логику +- Телефон уникален, но используются UUID для всех сущностей diff --git a/docs/ACCOUNT_PAGE.md b/docs/ACCOUNT_PAGE.md new file mode 100644 index 0000000..dbaa3c9 --- /dev/null +++ b/docs/ACCOUNT_PAGE.md @@ -0,0 +1,136 @@ +# Account Page — Страница аккаунта + +Единая страница `/account` объединяет профиль и настройки. Доступна по клику на аватар в сайдбаре. + +--- + +## Роут + +``` +app/[locale]/(sidebar)/account/page.tsx → src/features/account/account-page.tsx +``` + +## Структура файлов + +``` +src/features/account/ +├── account-page.tsx # Главная страница с табами +├── index.ts # Barrel export +└── tabs/ + ├── index.ts # Barrel export табов + ├── profile-tab.tsx # Личные данные, бизнес, аккаунт + ├── subscription-tab.tsx # Подписка и планы + ├── security-tab.tsx # Пароль, сессии, выход + └── appearance-tab.tsx # Тема, язык, уведомления +``` + +--- + +## Табы + +### 1. Profile (Профиль) + +| Секция | Что показывает | Действия | +| ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | +| Аватар + имя | Фото, ФИО, роль | Редактирование имени, загрузка/удаление фото (S3 presigned URL) | +| Личная информация | ФИО, телефон, роль | Inline-редактирование ФИО (react-hook-form + Zod) | +| Бизнес | Локация (название + мелкий ID), Organization ID | Копирование ID в буфер. Название подтягивается через `useLocation(location_id)` | +| Аккаунт | Дата создания, дата обновления | Кнопка «Удалить аккаунт» (presentational) | + +**Хуки:** `useCurrentUser`, `useUpdateAccount`, `useMediaUpload`, `useDeleteAvatar`, `useLocation` + +### 2. Subscription (Подписка) + +Данные планов захардкожены на фронте (3 плана): + +| План | Цена | Триал | Лимиты | +| ------- | ------------ | --------------- | ----------------------- | +| Solo | 5 000 ₸/мес | 2 мес бесплатно | 1 точка, 1 мастер | +| Point | 9 000 ₸/мес | 1 мес бесплатно | 1 точка, до 10 мастеров | +| Network | 25 000 ₸/мес | 1 мес бесплатно | ∞ точек, ∞ мастеров | + +Содержит: + +- Текущий план + статистика использования (локации, сотрудники) +- Toggleable сравнительная таблица планов с фичами +- Баннер «оплата вручную через менеджера» +- Таблица истории платежей (пока пустая) +- Способ оплаты (placeholder) + +**Данные подписки:** берутся из `user.subscription_limit` (тип `ISubscriptionLimit`) + +### 3. Security (Безопасность) + +| Секция | Описание | +| --------------- | ------------------------------------------------------------------------- | +| Пароль | Кнопка «Сменить пароль» → `usePasswordChangeRequest` (отправляет SMS-код) | +| Активные сессии | Текущая сессия + пример другой (presentational) | +| Выход | `useLogout` — выход из аккаунта | + +### 4. Appearance (Внешний вид) + +| Секция | Описание | +| ----------- | ------------------------------------------------------------------------------------- | +| Тема | Light / Dark / System — карточки через `next-themes`, с hydration guard | +| Язык | Русский / Қазақша — переключение через `next/navigation` router с сохранением `?tab=` | +| Уведомления | Push (через Service Worker + VAPID), SMS (включены), Email digest (выключен) | + +--- + +## URL-синхронизация табов + +Активный таб хранится в URL query param `?tab=`: + +``` +/account → Profile (по умолчанию, без ?tab=) +/account?tab=subscription +/account?tab=security +/account?tab=appearance +``` + +**Механизм:** + +- `useState` инициализируется из `useSearchParams().get("tab")` +- При смене таба — `window.history.replaceState()` обновляет URL без навигации +- При смене языка в Appearance — `?tab=` пробрасывается в новый URL + +Это решает проблему сброса таба при смене локали (вызывает полную перезагрузку страницы). + +--- + +## i18n + +Переводы в `messages/ru.json` и `messages/kz.json` в namespace `Account`: + +``` +Account.title +Account.tabs.{profile,subscription,security,appearance} +Account.Profile.* +Account.Subscription.* +Account.Security.* +Account.Appearance.* +``` + +--- + +## Скелетоны + +Каждый таб (кроме Appearance) имеет собственный скелетон-компонент: + +- `ProfileTabSkeleton` — аватар, секции с полями +- `SubscriptionTabSkeleton` — карточки статистики, таблица +- `SecurityTabSkeleton` — секции пароля и сессий + +Appearance использует hydration guard (`mounted` state) для темы вместо скелетона. + +--- + +## Удалённые файлы + +При создании Account были удалены: + +- `app/[locale]/(sidebar)/settings/` — старая страница настроек +- `src/features/settings/` — старый feature настроек +- `src/features/profile/` — старый feature профиля +- `src/widgets/ui/theme-toggle.tsx` — вынесен в appearance-tab +- Из сайдбара убран пункт «Settings» (`app-sidebar.tsx`) diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md new file mode 100644 index 0000000..0025a65 --- /dev/null +++ b/docs/API_ENDPOINTS.md @@ -0,0 +1,593 @@ +# API Endpoints + +Базовый URL: `NEXT_PUBLIC_API_URL` (например `https://api.bagsy.kz`) +Авторизация: `Authorization: Bearer ` (помечено как 🔒) +Swagger: `https://stage-backoffice.bagsy.kz/swagger/doc.json` + +--- + +## 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, organization_name } +Response: { registration_id, retry_after } +Errors: 400, 409, 500 +``` + +### `POST /api/v1/auth/register/verify` + +⚠️ Только лендинг. + +``` +Request: { phone, otp_code } +Response: { access_token, refresh_token } +``` + +### `POST /api/v1/auth/register/resend` + +⚠️ Только лендинг. + +``` +Request: { phone } +Response: { retry_after } +``` + +### `GET /api/v1/auth/verify/{token}` + +Проверка action-токена (инвайт, сброс пароля). + +``` +Params: token (path) +Response: { phone, purpose, org_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: { message } +Errors: 400, 401, 500 +``` + +--- + +## Appointments (бронирования) + +### `POST /api/v1/appointments` + +Создание записи на услугу (требует OTP-подтверждения). + +``` +Request: { client_phone, location_id, employee_id, service_id, date, time } +Response: { appointment_id, status: "pending" } +Errors: 400, 409, 500 +``` + +### `POST /api/v1/appointments/slots` + +Получение доступных слотов, сгруппированных по сотрудникам. + +``` +Request: { location_id, service_id, date_from, date_to } +Response: { slots: [{ employee_id, employee_name, price, slots: [{ start_at, end_at }] }] } +Errors: 400, 404, 500 +``` + +### 🔒 `POST /api/v1/appointments/direct` + +Прямое создание записи сотрудником (без OTP, сразу confirmed). + +``` +Request: { client_phone, location_id, employee_id, service_id, date, time } +Response: { appointment_id, status: "confirmed" } +Errors: 400, 401, 403, 409, 500 +``` + +### 🔒 `GET /api/v1/appointments/calendar` + +Календарь записей за период (макс. 35 дней). + +``` +Params: from (required), to (required), location_id?, employee_id?, include_cancelled? +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/appointments/{id}/confirm` + +Подтверждение записи OTP-кодом. + +``` +Params: id (path) +Request: { otp_code: string } +Response: 204 No Content +Errors: 400, 404, 500 +``` + +### 🔒 `POST /api/v1/appointments/{id}/cancel` + +Отмена записи (только сотрудники). + +``` +Params: id (path) +Request: { cancellation_reason?: string } +Response: 204 No Content +Errors: 400, 403, 404, 500 +``` + +### `POST /api/v1/appointments/{id}/resend-otp` + +Повторная отправка OTP подтверждения. + +``` +Params: id (path) +Response: 204 No Content +Errors: 400, 404, 500 +``` + +--- + +## Employees + +### 🔒 `GET /api/v1/employees` + +Список сотрудников с фильтрацией и пагинацией. + +``` +Params: location_id?, role[]?, search?, active?, limit?, offset?, order_by?, sort_order? +Response: { employees: [IEmployeeDto], total: number } +Errors: 400, 401, 403, 500 +``` + +### 🔒 `GET /api/v1/employees/me` + +Текущий авторизованный сотрудник. + +``` +Response: { + id, phone, first_name, last_name, avatar_url, role, + location_id, active, created_at, updated_at, + organization: { + id, name, + subscription: { + plan: "solo"|"point"|"network", + status: string, + current_period_end: string, + limits: { + locations: { used, max }, + employees: { used, max }, + bookings_monthly: { used, max } + }, + features: { + multi_location, sms_notifications, custom_branding, api_access + } + } + }, + permissions: { can_provide_services, can_manage_location_schedule } +} +``` + +### 🔒 `PUT /api/v1/employees/me` + +Обновление своего профиля. + +``` +Request: { first_name?, last_name?, avatar_id? } +Response: IEmployeeDto +Errors: 400, 401, 404, 410, 500 +``` + +### 🔒 `POST /api/v1/employees/invite` + +Приглашение сотрудника. + +``` +Request: { phone, first_name, last_name, role: "manager"|"staff", location_id } +Response: { invitation_id, retry_after } +Errors: 400, 401, 403, 409, 429, 500 +``` + +### `POST /api/v1/employees/invite/confirm` + +Подтверждение приглашения + установка пароля. + +``` +Request: { token, password } +Response: { access_token, refresh_token, employee } +Errors: 400, 404, 409, 410, 500 +``` + +### 🔒 `POST /api/v1/employees/invite/resend` + +Повторная отправка приглашения. + +``` +Request: { phone } +Response: { retry_after } +Errors: 400, 401, 403, 404, 429, 500 +``` + +### 🔒 `POST /api/v1/employees/{id}/activate` + +Активация сотрудника. Owner — любого, Manager — staff своей локации. + +``` +Params: id (path, UUID) +Response: 200 +Errors: 403, 404, 500 +``` + +### 🔒 `POST /api/v1/employees/{id}/deactivate` + +Деактивация сотрудника. Только Owner. + +``` +Params: id (path, UUID) +Response: 200 +Errors: 403, 404, 500 +``` + +### 🔒 `PATCH /api/v1/employees/{id}/role` + +Смена роли. Только Owner. + +``` +Params: id (path, UUID) +Request: { role: "owner"|"manager"|"staff" } +Response: 200 +Errors: 400, 403, 404, 500 +``` + +### 🔒 `PATCH /api/v1/employees/{id}/permissions` + +Смена разрешений. Owner — любому, Manager — staff своей локации. + +``` +Params: id (path, UUID) +Request: { can_provide_services?, can_manage_location_schedule? } +Response: 200 +Errors: 403, 404, 500 +``` + +### 🔒 `POST /api/v1/employees/{id}/transfer` + +Перевод сотрудника в другую локацию. Только Owner. + +``` +Params: id (path, UUID) +Request: { location_id } +Response: 200 +Errors: 400, 403, 404, 500 +``` + +### 🔒 `GET /api/v1/employees/{id}/services` + +Список услуг сотрудника с индивидуальными ценами. + +``` +Params: id (path, UUID) +Response: { services: [{ service_id, service_name, price, ... }] } +Errors: 400, 404, 500 +``` + +--- + +## Locations + +### 🔒 `GET /api/v1/locations` + +Список локаций организации. + +``` +Params: active?, limit?, offset?, order_by?, sort_order? +Response: { locations: [ILocationDto], total } +Errors: 400, 401, 403, 500 +``` + +### 🔒 `GET /api/v1/locations/{id}` + +Получение локации по ID. + +``` +Params: id (path, UUID) +Response: ILocationDto { + id, name, description, phone, slug, category_id, + schedule_type, slot_duration_minutes, active, created_at, + address: { city, street, building, details }, + coordinates: { latitude, longitude } +} +``` + +### 🔒 `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, prompt_org_profile: boolean } +Errors: 400, 401, 403, 500 +``` + +`prompt_org_profile` — бэкенд сигнализирует что organization.name не заполнено. + +### `GET /api/v1/locations/slug/{slug}` + +Публичный эндпоинт — локация по slug + расписание на 7 дней. + +``` +Params: slug (path) +Response: ILocationDto + schedule: [{ id, date, start_time, end_time }] +Errors: 404, 500 +``` + +### 🔒 `PUT /api/v1/locations/{id}` + +Обновление локации. Все поля опциональны. Только Owner. + +``` +Params: id (path, UUID) +Request: { + name?, phone?, schedule_type?, slot_duration_minutes?, + latitude?, longitude?, active?, + address?: { city?, street?, building?, details? } +} +Response: 204 No Content +Errors: 400, 401, 403, 404, 500 +``` + +> ⚠️ `description` пока отсутствует в swagger — будет добавлен. + +### 🔒 `DELETE /api/v1/locations/{id}` + +Удаление локации. + +``` +Params: id (path, UUID) +Response: 204 No Content +``` + +### `GET /api/v1/locations/categories` + +Категории бизнеса для создания локации. + +``` +Response: { categories: [{ id, name, slug, sort_order }] } +``` + +--- + +## Services + +### 🔒 `GET /api/v1/services/{id}` + +Список услуг локации (id = location_id). + +``` +Params: id (path, UUID локации) +Response: { services: [{ + id, name, description, category_id, color, + duration_minutes, min_price, max_price, sort_order, active +}] } +``` + +### 🔒 `POST /api/v1/services` + +Создание услуги. + +``` +Request: { name, description?, location_id, category_id, subcategory_id?, duration_minutes, color } +Response: { id, ... } +``` + +### 🔒 `PUT /api/v1/services/{id}` + +Обновление услуги. + +``` +Params: id (path, UUID) +Request: { name?, description?, duration_minutes?, color?, sort_order? } +Response: serviceResponse +``` + +### 🔒 `DELETE /api/v1/services/{id}` + +Удаление услуги. + +``` +Params: id (path, UUID) +Response: 204 No Content +``` + +### `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, service_id, price } +Response: { id } +Errors: 400, 403, 404, 422, 500 +``` + +--- + +## Schedules + +### 🔒 `GET /api/v1/employee-schedules/{employeeID}` + +Расписание сотрудника за период. + +``` +Params: employeeID (path, UUID), start (YYYY-MM-DD), end (YYYY-MM-DD) +Response: { slots: [{ date, is_working, ranges: [{ start, end }], breaks: [{ start, end }] }] } +``` + +### 🔒 `PUT /api/v1/employee-schedules/{employeeID}` + +Установка расписания сотрудника (заменяет все слоты за период). + +``` +Params: employeeID (path, UUID) +Request: { start, end, slots: [{ date, is_working, ranges, breaks }] } +Response: 204 No Content +Errors: 400, 401, 403, 404, 422, 500 +``` + +### 🔒 `DELETE /api/v1/employee-schedules/{employeeID}` + +Удаление расписания сотрудника за период. + +``` +Params: employeeID (path, UUID), start (YYYY-MM-DD), end (YYYY-MM-DD) +Response: 204 No Content +``` + +### 🔒 `GET /api/v1/location-schedules/{locationID}` + +Расписание локации за период. + +``` +Params: locationID (path, UUID), start (YYYY-MM-DD), end (YYYY-MM-DD) +Response: { slots: [...] } +``` + +### 🔒 `PUT /api/v1/location-schedules/{locationID}` + +Установка расписания локации. + +``` +Params: locationID (path, UUID) +Request: { start, end, slots: [...] } +Response: 204 No Content +``` + +### 🔒 `DELETE /api/v1/location-schedules/{locationID}` + +Удаление расписания локации за период. + +``` +Params: locationID (path, UUID), start (YYYY-MM-DD), end (YYYY-MM-DD) +Response: 204 No Content +``` + +--- + +## Media + +### 🔒 `POST /api/v1/media/upload` + +Генерация presigned URL для загрузки файла. + +``` +Request: { filename, mime_type, purpose: "avatars"|"organizations"|"locations"|"services"|"service-categories", size_bytes } +Response: { asset_id, upload_url, upload_fields: Record } +``` + +### 🔒 `POST /api/v1/media/{id}/confirm` + +Подтверждение загрузки файла. + +``` +Params: id (path, asset_id) +Response: 200 +``` + +--- + +## Organizations + +### 🔒 `PUT /api/v1/organizations/me` + +Обновление профиля организации (название и описание сети). +Используется при создании сети — когда владелец решает объединить локации под одним брендом. + +``` +Request: { name: string, description?: string } +Response: { id: string, name: string, description: string } +Errors: 400, 401, 403, 404, 500 +``` + +--- + +## Планы подписки (захардкожены на фронте) + +| Plan | Цена | Точки | Мастера | Триал | +| ------- | ------------ | ----- | ------- | --------------- | +| Solo | 5 000 ₸/мес | 1 | 1 | 2 мес бесплатно | +| Point | 9 000 ₸/мес | 1 | до 10 | 1 мес бесплатно | +| Network | 25 000 ₸/мес | ∞ | ∞ | 1 мес бесплатно | + +⚠️ API для подписок/оплат пока нет. Оплата производится вручную через WhatsApp. diff --git a/docs/API_MIGRATION.md b/docs/API_MIGRATION.md new file mode 100644 index 0000000..69330e7 --- /dev/null +++ b/docs/API_MIGRATION.md @@ -0,0 +1,146 @@ +# 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..5607acc --- /dev/null +++ b/docs/BACKEND_TABLES.md @@ -0,0 +1,505 @@ +-------------------------------------- + -- 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..890a72a --- /dev/null +++ b/docs/FLOW_ADD_MASTER.md @@ -0,0 +1,113 @@ +# ФЛОУ: Добавление сотрудника (инвайт) + +## 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..72c97e8 --- /dev/null +++ b/docs/FLOW_NETWORK.md @@ -0,0 +1,121 @@ +# ФЛОУ: 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 (locations.count >= 2 && organization.name == NULL) + → показать модалку "Объединить в сеть?" +``` + +Создание сети **необязательно** — пользователь может пропустить. +Если у организации 3 точки и добавляется 4-я — при создании сети все 4 объединяются под одним брендом. + +Модалка: + +``` +┌────────────────────────────────────────────────┐ +│ 🏢 ОБЪЕДИНИТЬ В СЕТЬ? │ +│ │ +│ У вас несколько точек — вы можете объединить │ +│ их в сеть. Это необязательно, но поможет │ +│ управлять всеми точками под одним брендом. │ +│ │ +│ Название сети (required) │ +│ Описание сети (optional) │ +│ │ +│ [Пропустить] [Создать сеть] │ +└────────────────────────────────────────────────┘ +``` + +Backend: `PUT /api/v1/organizations/me` — обновляет name, description. +Организация с заполненным `name` считается сетью. + +Модалка будет появляться при каждом создании локации, пока пользователь не задаст название. + +--- + +## Деактивация / активация локации + +На детальной странице локации бейдж статуса ("Активна" / "Неактивна") кликабелен. +По клику — модалка подтверждения: + +- **Деактивация**: предупреждение что клиенты не смогут записываться +- **Активация**: подтверждение что локация снова станет доступна + +Backend: `PUT /api/v1/locations/{id}` с `{ active: true/false }`. + +--- + +## Управление сетью (в ЛК) + +Frontend (app.bagsy.kz/locations) — доступно только owner: + +- Основная информация сети (название, описание) +- Статистика: количество локаций, сотрудников, записей, выручка +- Список всех локаций с кнопками управления +- [+ Добавить локацию] — до ~10 локаций + +--- + +## Управление расписанием (в ЛК) + +Frontend (app.bagsy.kz/schedule): + +- В хедере — **Select локации** (переключение между точками сети) +- Select виден только при >1 точки, по дефолту выбрана `user.location_id` +- Два таба: "График точки" и "Мой график" (логика fixed/mixed как в POINT) +- При смене точки — расписание и `schedule_type` перезагружаются +- Подробнее: `docs/SCHEDULE_PAGE.md` + +--- + +## Добавление сотрудников + +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..0a800a2 --- /dev/null +++ b/docs/FLOW_POINT.md @@ -0,0 +1,104 @@ +# ФЛОУ: 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/schedule): + +- Два таба: "График точки" и "Мой график" +- **Fixed** (`schedule_type: "fixed"`): таб "Мой график" — read-only (информер: "Ваш график определяется расписанием точки") +- **Mixed** (`schedule_type: "mixed"`): оба таба редактируемые, badge "Своё" для переопределённых дней сотрудника +- Права: `can_manage_location_schedule` → редактирование графика точки; `can_provide_services` → просмотр/редактирование своего +- Подробнее: `docs/SCHEDULE_PAGE.md` + +--- + +## Добавление мастеров (в ЛК) + +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..808b178 --- /dev/null +++ b/docs/FLOW_SOLO.md @@ -0,0 +1,94 @@ +# ФЛОУ: 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 + +--- + +## Управление расписанием (в ЛК) + +Frontend (app.bagsy.kz/schedule): + +- Solo plan → табов нет, единственный scope = "point" +- Владелец редактирует расписание локации напрямую +- Календарная сетка + правая панель (мобилка: bottom sheet) +- Пресеты: 5/2, чётные, нечётные дни +- Подробнее: `docs/SCHEDULE_PAGE.md` + +## Синхронизация расписаний (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..4490138 --- /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 доступ (в планах, пока не реализуем) +└── Идеально для: сетей салонов diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..6b62e96 --- /dev/null +++ b/docs/PROJECT_OVERVIEW.md @@ -0,0 +1,176 @@ +# 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 в календаре | +| vaul | — | Bottom sheet (Drawer) | +| sonner | — | Toast-уведомления | + +--- + +## Архитектура: Feature-Sliced Design (FSD) + +``` +app/ # Next.js App Router (страницы) +├── [locale]/(auth)/ # Логин, инвайт +├── [locale]/(sidebar)/ # Основные страницы с сайдбаром +│ ├── (dashboard)/ # Календарь (главная) +│ ├── staff/ # Сотрудники +│ ├── locations/ # Локации +│ ├── services/ # Услуги +│ └── account/ # Аккаунт (профиль + настройки, 4 таба) + +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 +│ ├── schedule/ # Расписание: календарь, editor, пресеты, bottom sheet +│ └── account/ # Аккаунт: профиль, подписка, безопасность, внешний вид +├── entities/ # UI-примитивы (shadcn/ui обёртки) +│ └── *.tsx # Button, Dialog, Drawer, 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/schedule-service.ts` | Расписание (employee/location schedules) | +| `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, useGetEmployeeServices, useActivateEmployee, useDeactivateEmployee, useChangeEmployeePermissions, useChangeEmployeeRole | +| `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/hooks/use-schedule-permissions.ts` | useSchedulePermissions (scope, rights по plan/type) | + +### Типы + +| Файл | Содержимое | +| ------------------------------ | ---------------------------------------------------- | +| `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/types/schedule.ts` | ScheduleScope, DaySchedule, MonthSchedule, TimeRange | + +### Утилиты + +| Файл | Назначение | +| ----------------------------------------- | -------------------------------------------------- | +| `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 | +| `src/shared/utils/avatar.ts` | getInitials (имя + фамилия → инициалы) | + +--- + +## Авторизация + +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..4bb2605 --- /dev/null +++ b/docs/REGISTRATION_FLOW.md @@ -0,0 +1,330 @@ +# ТЗ: Регистрация владельца (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/docs/SCHEDULE_PAGE.md b/docs/SCHEDULE_PAGE.md new file mode 100644 index 0000000..39b5d67 --- /dev/null +++ b/docs/SCHEDULE_PAGE.md @@ -0,0 +1,213 @@ +# Страница расписания (/schedule) + +Управление графиком работы сотрудников и локаций. Доступна из сайдбара. + +--- + +## Архитектура + +``` +app/[locale]/(sidebar)/schedule/ — страница +src/features/schedule/ +├── schedule-page-client.tsx — обёртка: загрузка user + location(s), permissions, skeleton +├── schedule-content.tsx — основной layout: табы, навигация, календарь + editor +├── schedule-header.tsx — заголовок + Select локации (network plan) +├── schedule-scope-context.tsx — контекст: scope (point|staff), locationId, scheduleType +├── api/ +│ └── use-month-schedule.ts — React Query хук: GET/PUT/DELETE schedule за месяц +└── ui/ + ├── month-grid.tsx — календарная сетка 7×N с визуальными состояниями + ├── schedule-editor.tsx — правая панель: редактор расписания выбранных дней + ├── schedule-presets.tsx — пресеты (5/2, чётные, нечётные) + ├── schedule-bottom-sheet.tsx — Vaul Drawer (мобильный editor) + ├── schedule-skeleton.tsx — скелетоны календаря и editor + └── time-range-row.tsx — строка времени (start–end + удаление) +``` + +Хук прав: `src/shared/hooks/use-schedule-permissions.ts` + +--- + +## Scope и права доступа + +Два scope: **point** (расписание локации) и **staff** (личный график сотрудника). + +### Определение прав (`useSchedulePermissions`) + +| План / Тип | Табы | point scope | staff scope | +| ------------------------- | ------------- | --------------------------------------------------- | ------------------------------------------- | +| **Solo** | Скрыты | Полный доступ (единственный scope) | — | +| **Fixed** (point/network) | point + staff | Редактируемый (если `can_manage_location_schedule`) | Read-only (показывает расписание точки) | +| **Mixed** (point/network) | point + staff | Редактируемый (если `can_manage_location_schedule`) | Редактируемый (если `can_provide_services`) | + +- Solo plan определяется по `user.organization.subscription.plan === "solo"` +- При fixed + staff scope — информер "Ваш график определяется расписанием точки" +- `schedule_type` берётся из `GET /api/v1/locations/{id}` + +### Выбор локации (Network plan) + +Для плана **network** (несколько точек) в header показывается Select с локациями: + +- `useLocations()` загружает список (только для owner) +- Select отображается если `plan === "network"` и `locations.length > 1` +- По умолчанию выбрана `user.location_id` +- При смене локации — пересчитывается `schedule_type`, сбрасываются выбранные дни, перезагружается расписание +- `locationId` передаётся через `ScheduleScopeContext` в `ScheduleContent` + +--- + +## Календарная сетка (MonthGrid) + +### Визуальные состояния ячеек + +| Состояние | Стили | +| ------------------------------- | ------------------------------------------------------- | +| Рабочий день (есть расписание) | `bg-accent-50/80 border-accent-200` (голубой фон) | +| Выходной (Сб/Вс без расписания) | `bg-muted/50` (серый фон) | +| Прошедший день | `opacity-40 pointer-events-none` | +| Выбранный | `border-2 border-accent-500` (синяя рамка, без заливки) | +| Сегодня | `font-semibold`, цвет accent | +| Обычный будний без расписания | `border-border bg-card` | + +### Адаптивность + +- **Мобилка**: однобуквенные дни (П, В, С...), компактное время "9-18", `min-h-[44px]` +- **Десктоп**: полные сокращения (Пн, Вт, Ср...), время "09–18", `min-h-[56px]` + +### Взаимодействие + +- **Клик** — toggle выбора дня (прошедшие дни заблокированы) +- **Shift+клик** — выбор диапазона от последнего выбранного +- Автоматическое открытие закрытых дней при выборе (дефолт 09:00–18:00) +- `autoOpenedDaysRef` отслеживает авто-открытые дни для отката при deselect (включая shift+клик и пресеты) + +--- + +## Редактор расписания (ScheduleEditor) + +Правая панель / мобильный bottom sheet. + +### Заголовок + +- Мелко: "Выбрано: N дн." +- Крупнее (при single select): "Вторник, 31 марта" +- Badge "Своё" — только для mixed staff scope, когда расписание сотрудника отличается от расписания точки (не показывается при редактировании точки) + +### Содержимое + +- **Рабочие интервалы** — список TimeRangeRow (start–end), кнопка "Добавить интервал" +- **Перерывы** — аналогичный список +- Предупреждение если у выбранных дней разное время (mixed times warning) +- Если день закрыт — сообщение + кнопка "Сделать рабочим" + +### Кнопки + +- **Сохранить** (`size="lg"`) — видна только при `isDirty`, показывает count при multi-select +- **Выходной** — кнопка `variant="outline"` destructive, сразу сохраняет на бэк (без необходимости жать "Сохранить") + +--- + +## Пресеты (SchedulePresets) + +| Пресет | Логика | +| ----------- | ------------------------------------------- | +| 5/2 (Пн–Пт) | Будни — рабочие (09–18), выходные — закрыты | +| Чётные | Чётные дни — рабочие, нечётные — закрыты | +| Нечётные | Нечётные дни — рабочие, чётные — закрыты | + +- На мобилке — горизонтальный скролл (`overflow-x-auto flex-nowrap`) +- На десктопе — flex-wrap + подсказка "Shift+клик для выделения диапазона" +- После применения пресета на мобилке — автоматически открывается bottom sheet +- Пресеты регистрируют рабочие дни в `autoOpenedDaysRef` через `onMarkAutoOpened` — при deselect без сохранения дни откатываются +- Скрыты в read-only режиме + +--- + +## Мобильная адаптация + +- **Десктоп**: два столбца `grid-cols-[1fr_280px]`, editor в sticky Card +- **Мобилка**: один столбец, editor в Vaul Drawer (bottom sheet с handle bar, `max-h-[80vh]`) +- Drawer открывается при выборе дня или пресета, закрывается при сбросе выделения +- `DrawerTitle className="sr-only"` для a11y (Radix требует DialogTitle) + +--- + +## Валидация + +При сохранении (`handleSave`) проверяется **только для изменённых (dirty) дней**: + +1. **end <= start** — время окончания раньше или равно началу → toast `endBeforeStart` +2. **Пересечение рабочих интервалов** → toast `overlappingRanges` +3. **Перерыв вне рабочих часов** — перерыв не помещается внутрь ни одного рабочего интервала → toast `breakOutsideWork` +4. **Пересечение перерывов** → toast `overlappingBreaks` + +`validateSchedule(schedule, days?)` принимает опциональный фильтр дней. Нетронутые дни не валидируются — это избегает ложных ошибок из-за round-trip маппинга. + +Toast-уведомления через Sonner: `savedSuccess`, `savedError`, `dayOffSuccess`. + +--- + +## Данные + +### useMonthSchedule + +Хук загружает расписание за месяц, предоставляет `save()` для PUT и навигацию по месяцам. + +- Scope `"point"` → `GET/PUT /api/v1/location-schedules/{locationID}` +- Scope `"staff"` → `GET/PUT /api/v1/employee-schedules/{employeeID}` +- Параметры `start` / `end` — первый и последний день месяца (`YYYY-MM-DD`) + +### Partial save (dirty days) + +`save(data, days?)` сужает диапазон до изменённых дней: + +- `start`/`end` сужается до `min(days)..max(days)` — API перезаписывает только этот диапазон +- **Слоты включают ВСЕ дни в диапазоне `min..max`**, не только dirty — иначе бэкенд удалит промежуточные дни (баг: выходные [18, 26] сбрасывали дни 19–25) +- `dirtyDaysRef` в `schedule-content.tsx` трекает какие дни менялись (через `markDirty`) +- Если `days` не указан — отправляется весь месяц (fallback) + +### Round-trip маппинг + +При отправке на бэк `splitWorkByBreaks()` разрезает work-ranges по перерывам: +`work 9-18 + break 13-14` → `[work 9-13, rest 13-14, work 14-18]` + +При загрузке обратно `mergeAdjacentWork()` склеивает обратно: +`[work 9-13, work 14-18] + break 13-14` → `[work 9-18]` + `break 13-14` + +Склеивание происходит **только** если промежуток между work-ranges точно совпадает с break. Два независимых work-range (например `9-13` и `15-18` без break в промежутке) остаются как есть. + +### Dual fetch для mixed staff + +При `activeScope === "staff"` и `schedule_type === "mixed"` — дополнительный запрос расписания точки для определения badge "Своё" (кастомный override). + +### Локальный стейт + +`localSchedule` — копия серверных данных для редактирования. Синхронизируется с сервером при загрузке и смене месяца. + +`dirtyDaysRef: Set` трекает номера изменённых дней. `isDirty` — derived state (`dirtyDaysRef.current.size > 0`). При сохранении/сбросе/sync — очищается через `clearDirty()`. + +--- + +## Скелетоны + +- `ScheduleCalendarSkeleton` — сетка 7×5 с skeleton-ячейками +- `ScheduleEditorSkeleton` — Card с skeleton-линиями (заголовок, время, кнопка) +- Показываются при загрузке user/location и при загрузке расписания месяца + +--- + +## i18n ключи + +Namespace `Schedule`: + +- `pointSchedule`, `mySchedule` — табы +- `selectedDay`, `selectedDays` — подзаголовок editor +- `shiftHint` — подсказка Shift+клик (desktop) +- `saveCount` — кнопка "Сохранить (N)" +- `dayOff` — кнопка "Выходной" +- `customBadge` — badge "Своё" +- `fixedScheduleInfo` — информер fixed schedule +- `savedSuccess`, `savedError`, `dayOffSuccess` — toast +- `Editor.endBeforeStart`, `Editor.overlappingRanges`, `Editor.breakOutsideWork` — ошибки валидации +- `Presets.weekdays`, `Presets.evenDays`, `Presets.oddDays` — пресеты +- `Weekdays.mon`..`sun` — дни недели diff --git a/docs/SERVICES_PAGE.md b/docs/SERVICES_PAGE.md new file mode 100644 index 0000000..c56ce40 --- /dev/null +++ b/docs/SERVICES_PAGE.md @@ -0,0 +1,58 @@ +# Services Page + +Страница управления услугами локации (`/[locale]/services`). + +## Доступ + +- **Owner** — видит все локации, может выбрать локацию (network-план) +- **Manager** — видит только свою локацию +- **Staff** — нет доступа + +## Компоненты + +``` +app/[locale]/(sidebar)/services/page.tsx + └── ServicesHeader + ServicesContent + +src/features/services/ + services-header.tsx — заголовок с SidebarTrigger + services-content.tsx — оркестратор (данные, состояние drawer) + components/ + service-list.tsx — таблица с группировкой по категориям + service-row.tsx — строка услуги (grid layout) + service-row-actions.tsx — DropdownMenu (⋯): Edit, Staff, Duplicate, Delete + service-drawer.tsx — адаптивный drawer: Sheet (right) на десктопе, Vaul bottom-sheet на мобилке + service-details-tab.tsx — таб "Детали" — форма редактирования (PUT) + service-staff-tab.tsx — таб "Сотрудники" — привязка мастеров + delete-service-dialog.tsx — диалог подтверждения удаления + add-service-dialog.tsx — диалог создания новой услуги + add-service-form.tsx — форма создания услуги + location-select.tsx — селектор локации (network) + error-message.tsx — ошибки загрузки +``` + +## Interaction + +1. **Таблица** — grid-layout, группировка по category_id. Клик по строке → Sheet (Details tab) +2. **Drawer** — адаптивный: Sheet (right) на десктопе / Vaul bottom-sheet на мобилке, два таба: + - **Details** — редактирование name, description, duration, color, sort_order (PUT /api/v1/services/{id}) + - **Staff** — привязка сотрудников (POST /api/v1/employee-services), отвязка (DELETE — TODO, эндпоинт скоро) +3. **DropdownMenu (⋯)** — Edit, Manage staff, Duplicate, Delete +4. **Delete** — Dialog с подтверждением → DELETE /api/v1/services/{id} +5. **Duplicate** — POST /api/v1/services с "(copy)" в имени + +## API эндпоинты + +| Метод | URL | Описание | +| ------ | ------------------------------ | ------------------------------ | +| GET | /api/v1/services/{locationId} | Список услуг локации | +| POST | /api/v1/services | Создание услуги | +| PUT | /api/v1/services/{id} | Обновление (все поля optional) | +| DELETE | /api/v1/services/{id} | Soft-delete | +| POST | /api/v1/employee-services | Привязка сотрудника | +| DELETE | /api/v1/employee-services/{id} | Отвязка (TODO) | + +## Локация + +- **solo/point** — одна локация, автоматически выбрана +- **network** — Owner видит Select с локациями организации diff --git a/docs/STAFF_PAGE.md b/docs/STAFF_PAGE.md new file mode 100644 index 0000000..e4b924f --- /dev/null +++ b/docs/STAFF_PAGE.md @@ -0,0 +1,177 @@ +# Staff Page + +Страница управления сотрудниками (`/[locale]/staff`). + +## Доступ + +- **Owner** — полный доступ: приглашение, смена роли, трансфер, активация/деактивация +- **Manager** — может видеть список, управлять правами (ABAC), но не менять роли +- **Staff** — нет доступа к странице + +## Компоненты + +``` +app/[locale]/(sidebar)/staff/page.tsx + └── StaffHeader + StaffContent + +src/features/staff/ + staff-content.tsx — оркестратор (данные, фильтры, пагинация, drawer) + staff-filters.tsx — панель фильтров + кнопка приглашения + add-staff-dialog.tsx — диалог приглашения нового сотрудника + constants.ts — DEFAULT_STAFF_FILTERS (limit=15, sort_order=desc) + + components/ + table-row.tsx — строка сотрудника (grid layout, как в услугах) + employee-row-actions.tsx — DropdownMenu (⋯): View, Change role, Transfer, Activate/Deactivate + sort-icon.tsx — иконка направления сортировки + pagination.tsx — пагинация + счётчик лимита подписки + error-message.tsx — блок ошибки загрузки + + drawer/ + employee-drawer.tsx — адаптивный drawer сотрудника + profile-tab.tsx — таб «Профиль»: инфо, права (ABAC), статистика + services-tab.tsx — таб «Услуги»: список услуг сотрудника + portfolio-tab.tsx — таб «Портфолио»: плейсхолдер + index.ts — barrel-экспорт + + utils/ + format-role.ts — getRoleKey (роль → i18n-ключ) + avatar-color.ts — утилиты цвета аватара +``` + +## Таблица + +Grid-layout (аналогично `service-row.tsx`), контейнер `border rounded-lg overflow-hidden`: + +| Колонка | Desktop | Mobile | +| -------- | --------------------------- | ------------ | +| Имя | Аватар + имя (сортируемо) | + роль снизу | +| Телефон | `text-muted-foreground` | скрыт | +| Роль | `Badge variant="secondary"` | скрыт | +| Статус | `Badge default/outline` | скрыт | +| Действия | `EmployeeRowActions` (⋯) | ✓ | + +Grid columns: + +- Mobile: `grid-cols-[1fr_32px]` +- Desktop: `grid-cols-[1fr_120px_100px_90px_32px]` + +Заголовки колонок: `bg-muted/30`, только `hidden md:grid`. +Строки: `hover:bg-muted/50`, неактивные: `opacity-50`. +Выбранная строка: `bg-muted/50`. + +## Фильтры (`staff-filters.tsx`) + +| Элемент | Mobile | Desktop | +| ---------- | ------------- | -------------- | ---------------- | +| Поиск | full-width | `flex-1` | +| Локация | `flex-1` | `w-[160px]` | (только network) | +| Роль | `flex-1` | `w-[140px]` | +| Статус | `flex-1` | `w-[140px]` | +| Кнопка `+` | только иконка | иконка + текст | + +Поиск: debounce 500 мс, бэкенд параметр `search` (ILIKE по имени/фамилии/телефону). + +**Лимит подписки**: если `employeeLimits.used >= employeeLimits.max` (и `max != null`): + +- Кнопка `+` становится `variant="outline"` +- Клик перенаправляет на `/account?tab=subscription` + +## Drawer сотрудника (`employee-drawer.tsx`) + +Адаптивный: + +- **Mobile** (`useIsMobile()`): Vaul `Drawer` — bottom sheet, свайп вниз +- **Desktop**: `Sheet` — правая панель + +3 таба: + +1. **Профиль** — телефон, локация, статус-бадж, дата регистрации, ABAC-свичи, статистика +2. **Услуги** — список услуг сотрудника (`useGetEmployeeServices`), скелетоны, итого +3. **Портфолио** — плейсхолдер + +### Статус-бадж + +- Кликабельный (если план ≠ solo) → открывает диалог подтверждения +- Solo-план: бадж некликабельный, деактивация недоступна + +### ABAC-свичи (Profile tab) + +| Параметр | Описание | +| ------------------------------ | --------------------------- | +| `can_provide_services` | Может оказывать услуги | +| `can_manage_location_schedule` | Может управлять расписанием | + +Optimistic update: при переключении сразу обновляет кэш всех запросов `["employees"]`, откатывает при ошибке. + +## DropdownMenu (`employee-row-actions.tsx`) + +| Пункт | Условие отображения | Действие | +| ------------ | ---------------------------------------------------------------------- | ----------------------- | +| View Profile | всегда | открыть drawer | +| Change Role | owner + не сам себя | Dialog: select role | +| Transfer | owner + network-план + не сам себя | Dialog: select location | +| Detach | network-план + не сам себя + (owner ИЛИ manager для staff своей точки) | Dialog подтверждения | +| --- | — | separator | +| Deactivate | не solo-план + активен | Dialog подтверждения | +| Activate | не solo-план + неактивен | Dialog подтверждения | + +## Оптимистичные обновления + +Хелпер `optimisticUpdateEmployee` в `user-staff.ts`: + +- При мутации (permissions, activate, deactivate, role) — мгновенно обновляет все кэши `["employees"]` +- `selectedEmployee` в `staff-content.tsx` — **derived state** из `data.employees`, а не `useState` — гарантирует актуальность данных в drawer после optimistic update + +## Пагинация (`pagination.tsx`) + +Показывает в одном блоке: + +- Строка «Показано X-Y из Z» + кнопки «<» / «>» / «X из Y» +- Строка «X / Y лимит сотрудников» (если `max = null` → «∞») + +## API эндпоинты + +| Метод | URL | Описание | +| ----- | ---------------------------------- | ------------------------- | +| GET | /api/v1/employees | Список с фильтрами | +| POST | /api/v1/employees/invite | Приглашение | +| PUT | /api/v1/employees/{id}/permissions | Смена ABAC-прав | +| PUT | /api/v1/employees/{id}/role | Смена роли | +| PUT | /api/v1/employees/{id}/location | Трансфер в другую локацию | +| POST | /api/v1/employees/{id}/activate | Активация | +| POST | /api/v1/employees/{id}/deactivate | Деактивация | +| GET | /api/v1/employees/{id}/services | Услуги сотрудника | + +### Параметры GET /employees + +| Параметр | Тип | Описание | +| ------------- | ------------- | ----------------------------------- | +| `search` | string | ILIKE по имени / фамилии / телефону | +| `role` | TUserRole[] | Фильтр по роли | +| `active` | boolean | Фильтр по статусу активности | +| `location_id` | string (UUID) | Фильтр по локации (network) | +| `order_by` | field name | Поле сортировки | +| `sort_order` | asc / desc | Направление сортировки | +| `limit` | number | Размер страницы (default 10) | +| `offset` | number | Смещение для пагинации | + +## Ролевая модель — ограничения UI + +| Действие | Owner | Manager | Staff | +| --------------------- | ----------- | ------- | ----- | +| Видеть список | ✓ | ✓ | ✗ | +| Пригласить | ✓ | ✗ | ✗ | +| Сменить роль | ✓ | ✗ | ✗ | +| Трансфер (network) | ✓ | ✗ | ✗ | +| Активация/деактивация | ✓ (не solo) | ✗ | ✗ | +| Управлять правами | ✓ | ✓ | ✗ | + +## i18n ключи (messages/ru.json, kz.json) + +Все ключи в `Staff.*`: + +- `Staff.filters.*` — поиск, фильтры +- `Staff.roles.*` — owner, manager, staff +- `Staff.drawer.*` — все тексты drawer (профиль, статусы, права, модалки) +- `Staff.pagination.*` — тексты пагинации diff --git a/messages/kz.json b/messages/kz.json index da0a753..15569c3 100644 --- a/messages/kz.json +++ b/messages/kz.json @@ -1,17 +1,26 @@ { "LocaleLayout": { "title": "Bagsy — жазбаларды басқару" }, + "Offline": { + "title": "Байланыс жоқ", + "description": "Интернет байланысын тексеріңіз. Байланыс қалпына келгенде бет автоматты түрде ашылады.", + "retry": "Қайталау" + }, "Sidebar": { "sidebar": "Боковое меню", "sidebarDescription": "Боковое меню көрсетеді.", "Main": { "calendar": "Күнтізбе", + "schedule": "Жұмыс графигі", "clients": "Клиенттері", "services": "Қызметтер", "staff": "Жұмысшылар", - "points": "Орындар", + "locations": "Локациялар", "analytics": "Аналитика", "settings": "Параметрлер" }, + "LocationSelect": { + "label": "Локация" + }, "User": { "name": "Атауы", "phone": "Телефон", @@ -19,6 +28,13 @@ "logout": "Шығу" } }, + "PwaInstallPrompt": { + "add-to-home-screen": "Басты экранға қосу", + "share": "Бөлісу", + "to-home-screen": "Басты экранға", + "install-app": "Қолданбасын орнату", + "install": "Орнату" + }, "Dashboard": { "content": { "calendar": "Күнтізбе" }, "title": { @@ -26,10 +42,10 @@ "welcome": "Кош келдіңіз!", "panel": "Панель {name}" }, - "emptyPoints": { - "title": "Қызмет көрсету нүктелері жоқ", - "description": "Сізде әлі қызмет көрсету нүктелері жоқ. Күнтізбемен жұмыс істеу үшін бірінші нүктені қосыңыз.", - "addPointButton": "Нүкте қосу" + "emptyLocations": { + "title": "Қызмет көрсету локациялары жоқ", + "description": "Сізде әлі қызмет көрсету локациялары жоқ. Күнтізбемен жұмыс істеу үшін бірінші локацияны қосыңыз.", + "addPointButton": "Локация қосу" }, "Settings": { "calendarSettings": "Кесте және күнтізбе параметрлері", @@ -46,7 +62,7 @@ "to": "До", "closed": "Жабық", "badgeVariant": "Жүктеме түрі", - "dot": "Нүктелер", + "dot": "Локациялар", "colored": "Түсті", "mixed": "Қосымша", "workingHoursTooltip": "Жұмыс уақыты - бұл уақыт, мұнда сотрудник клиенттерді қабылдай алады, олардың уақытын өзгертеді және өзінің жұмыс графигін өзгертеді.", @@ -104,14 +120,35 @@ "comment": "Пікір", "commentDescription": "Қосымша ақпарат", "add": "Жазбаны қосу", - "cancel": "Бас тарту" + "notLinkedService": "Байланыстырылмаған", + "notLinkedMaster": "Байланыстырылмаған", + "cancel": "Бас тарту", + "errors": { + "firstNameRequired": "Атыңызды енгізіңіз", + "lastNameRequired": "Тегіңізді енгізіңіз", + "phoneRequired": "Телефон нөмірін енгізіңіз", + "serviceRequired": "Қызметті таңдаңыз", + "startDateRequired": "Басталу күнін таңдаңыз", + "selectStaff": "Қызметкерді таңдаңыз", + "selectLocation": "Нүктені таңдаңыз", + "selectLocationFirst": "Алдымен нүктені таңдаңыз" + } }, "EventDetailsDialog": { "edit": "Өңдеу", + "cancel": "Жазбаны болдырмау", + "cancelConfirm": "Бұл жазбаны болдырмағыңыз келетініне сенімдісіз бе?", + "cancelReason": "Болдырмау себебі", + "cancelReasonPlaceholder": "Себебін көрсетіңіз (міндетті емес)", + "cancelSuccess": "Жазба болдырмалды", + "cancelError": "Жазбаны болдырмау мүмкін болмады", + "cancelling": "Болдырмау...", "responsible": "Қызметкер", "startDate": "Басталу күні және уақыты", "endDate": "Аяқталу күні және уақыты", - "comment": "Пікір" + "comment": "Пікір", + "client": "Клиент", + "status": "Күйі" }, "DayCell": { "more": "тағы" @@ -201,8 +238,8 @@ "active": "Белсенді", "role": "Рөл", "inactive": "Белсенді емес", - "pointCode": "Нүкте коды", - "networkCode": "Желі коды", + "locationId": "Локация", + "networkCode": "Ұйым", "createdAt": "Жасалған күні", "updatedAt": "Соңғы жаңарту", "editProfile": "Профильді өңдеу", @@ -223,13 +260,155 @@ "errorRemovingAvatar": "Аватарды жою қатесі" } }, + "Account": { + "title": "Аккаунт", + "tabs": { + "profile": "Профиль", + "subscription": "Жазылым", + "security": "Қауіпсіздік", + "appearance": "Сыртқы көрініс" + }, + "Profile": { + "changePhoto": "Суретті өзгерту", + "removePhoto": "Суретті жою", + "personalInfo": "Жеке ақпарат", + "fullName": "Толық аты", + "firstName": "Аты", + "lastName": "Тегі", + "phone": "Телефон", + "email": "Email", + "notSet": "Көрсетілмеген", + "role": "Рөл", + "edit": "Өзгерту", + "add": "Қосу", + "save": "Сақтау", + "cancel": "Болдырмау", + "business": "Бизнес", + "businessName": "Бизнес атауы", + "location": "Локация", + "organizationId": "Ұйым", + "copy": "Көшіру", + "сopied": "Көшірілді", + "account": "Аккаунт", + "createdAt": "Құрылған", + "updatedAt": "Жаңартылған", + "deleteAccount": "Аккаунтты жою", + "deleteAccountDescription": "Бұл әрекетті қайтару мүмкін емес. Барлық деректер жойылады.", + "nameMinError": "Аты кемінде 2 таңбадан тұруы керек", + "surnameMinError": "Тегі кемінде 2 таңбадан тұруы керек", + "profileUpdatedSuccessfully": "Профиль сәтті жаңартылды", + "errorUpdatingProfile": "Профиль жаңарту қатесі", + "uploadError": "Сурет жүктеу қатесі", + "avatarRemovedSuccess": "Аватар жойылды", + "errorRemovingAvatar": "Аватарды жою қатесі" + }, + "Subscription": { + "currentPlan": "Ағымдағы жоспар", + "active": "Белсенді", + "trial": "Сынақ", + "expired": "Мерзімі өтті", + "cancelled": "Бас тартылды", + "nextPayment": "Келесі төлем", + "usedOfMax": "{used} / {max}", + "amount": "Сома", + "bookings": "Жазбалар", + "staffSlots": "Шеберлер", + "locations": "Локациялар", + "unlimited": "∞", + "upgradePlan": "Жоспарды жақсарту", + "comparePlans": "Жоспарларды салыстыру", + "manualPaymentWarning": "Төлем әзірге қолмен жүргізіледі. Ұзарту үшін бізбен байланысыңыз", + "whatsApp": "WhatsApp", + "paymentHistory": "Төлем тарихы", + "date": "Күні", + "description": "Сипаттама", + "status": "Күйі", + "paid": "Төленді", + "noPayments": "Төлемдер жоқ", + "paymentMethod": "Төлем әдісі", + "noPaymentMethod": "Төлем әдісі байланыстырылмаған", + "addCard": "Карта байланыстыру", + "perMonth": "/ ай", + "popular": "Танымал", + "trialMonths": "Алғашқы {count} ай тегін", + "trialMonth": "Бірінші ай тегін", + "plans": { + "solo": { + "name": "Solo", + "description": "Жеке шеберлер үшін", + "locations": "1 локация", + "staff": "1 шебер" + }, + "point": { + "name": "Point", + "description": "Шағын салондар үшін", + "locations": "1 локация", + "staff": "10 шеберге дейін" + }, + "network": { + "name": "Network", + "description": "Салон желілері үшін", + "locations": "Шексіз локациялар", + "staff": "Шексіз шеберлер" + } + }, + "features": { + "unlimitedBookings": "Шексіз жазбалар", + "basicCrm": "Негізгі CRM", + "notifications": "Хабарландырулар (SMS / WhatsApp / Email)", + "onlineBooking": "Жарнамасыз онлайн жазба", + "reminders": "Жазба еске салулары", + "allFromSolo": "Solo-дағы барлығы", + "advancedCrm": "Кеңейтілген CRM", + "analytics": "Аналитика және есептер", + "finance": "Қаржылық есеп", + "inventory": "Қойма есебі", + "allFromPoint": "Point-тағы барлығы", + "multiBranch": "Мультифилиалдылық", + "consolidatedAnalytics": "Барлық локациялар бойынша жиынтық аналитика", + "apiAccess": "API қолжетімділік", + "inDevelopment": "әзірленуде", + "planned": "жоспарда" + } + }, + "Security": { + "password": "Құпия сөз", + "lastChanged": "Соңғы өзгерту", + "never": "ешқашан", + "changePassword": "Құпия сөзді өзгерту", + "activeSessions": "Белсенді сессиялар", + "currentSession": "ағымдағы сессия", + "revoke": "Жою", + "logoutAll": "Барлық құрылғылардан шығу", + "daysAgo": "{count} күн бұрын" + }, + "Appearance": { + "theme": "Тема", + "light": "Жарық", + "dark": "Қараңғы", + "system": "Жүйелік", + "language": "Тіл", + "russian": "Русский", + "kazakh": "Қазақша", + "notifications": "Хабарландырулар", + "smsReminders": "Клиенттерге SMS-еске салу", + "smsRemindersDescription": "Жазбадан 2 сағат бұрын жіберу", + "dailyDigest": "Email дайджест", + "dailyDigestDescription": "Ертеңгі жазбалардың қысқаша мазмұны", + "notSupported": "Браузеріңіз хабарландыруларды қолдамайды", + "blocked": "Хабарландырулар бұғатталған. Браузер параметрлерінде рұқсат беріңіз", + "pushNotifications": "Push-хабарландырулар", + "pushDescription": "Жаңа жазбалар туралы хабарландырулар алу", + "calendar": "Күнтізбе" + } + }, "Staff": { "title": "Жұмысшылар", "tableTitle": "Жұмысшылар", "addStaff": "Жұмысшы қосу", "searchPlaceholder": "Аты, телефон, рөл бойынша іздеу...", "networkCode": "Желі коды", - "pointCode": "Нүкте коды", + "locationId": "Локация", "role": "Рөл", "name": "Аты", "surname": "Тегі", @@ -247,27 +426,79 @@ "accessDeniedDescription": "Жұмысшылар тізімін көруге құқығыңыз жоқ", "errorLoadingData": "Деректерді жүктеу қатесі", "unknownError": "Белгісіз қате", + "invite": "Шақыру", + "employeesCount": "қызметкер", + "staffLimit": "қызметкер лимиті", "filters": { - "pointCode": "Нүкте коды", - "pointCodePlaceholder": "Нүкте кодын енгізіңіз", - "networkCode": "Желі коды", + "searchByNameOrPhone": "Аты немесе телефон бойынша іздеу...", + "allRoles": "Барлық рөлдер", + "allStatuses": "Барлық күйлер", + "allLocations": "Барлық локациялар", + "active": "Белсенді", + "inactive": "Белсенді емес", + "locationId": "Локация", + "locationIdPlaceholder": "Локация UUID енгізіңіз", + "networkCode": "Ұйым", "networkCodePlaceholder": "Желі кодын енгізіңіз", "role": "Рөл", "rolePlaceholder": "Рөлді таңдаңыз", - "allRoles": "Барлық рөлдер", - "phone": "Телефон", - "phonePlaceholder": "Телефон енгізіңіз", + "search": "Поиск", + "searchPlaceholder": "Тел. немесе фио енгізіңіз", "add": "Қосу", "clear": "Тазалау", "phones": "Телефондар" }, + "drawer": { + "profile": "Профиль", + "services": "Қызметтер", + "portfolio": "Портфолио", + "info": "Ақпарат", + "phone": "Телефон", + "location": "Локация", + "status": "Күйі", + "joined": "Кіру күні", + "permissions": "Қол жетімділік құқықтары", + "canProvideServices": "Қызмет көрсетеді", + "canProvideServicesDesc": "Клиенттерді қабылдай алады", + "canManageSchedule": "Кесте басқару", + "canManageScheduleDesc": "Локация жұмыс уақытын өзгерту", + "quickStats": "Статистика", + "servicesCount": "қызмет", + "thisWeek": "осы аптада", + "rating": "рейтинг", + "actions": "Әрекеттер", + "changeRole": "Рөлді өзгерту", + "transfer": "Локацияға ауыстыру", + "deactivate": "Белсенді емес", + "activate": "Белсенді", + "totalServices": "Барлық қызметтер", + "priceRange": "Баға диапазоны", + "min": "мин", + "noServices": "Байланысқан қызметтер жоқ", + "portfolioPlaceholder": "Портфолио жақында қолжетімді болады", + "viewProfile": "Профильді көру", + "changeRoleTitle": "Рөлді өзгерту", + "changeRoleDesc": "Қызметкер үшін жаңа рөлді таңдаңыз", + "transferTitle": "Басқа локацияға ауыстыру", + "transferDesc": "Ауыстыру үшін локацияны таңдаңыз", + "selectRole": "Рөлді таңдаңыз", + "selectLocation": "Локацияны таңдаңыз", + "save": "Сақтау", + "cancel": "Болдырмау", + "deactivateTitle": "Қызметкерді өшіру", + "deactivateDesc": "Қызметкерді өшіргіңіз келе ме? Оған жазыла алмайды.", + "activateTitle": "Қызметкерді белсендіру", + "activateDesc": "Қызметкерді белсендіргіңіз келе ме? Оған жазыла алады.", + "confirm": "Растау", + "cannotDeactivateSelf": "Өзіңізді өшіре алмайсыз", + "detach": "Локациядан ажырату", + "detachTitle": "Локациядан ажырату", + "detachDesc": "{name} ағымдағы локациядан ажыратылады. Осы қызметкерге барлық жазбалар жойылады." + }, "roles": { - "staff": "Қызметкер", + "owner": "Иесі", "manager": "Менеджер", - "net_manager": "Желі менеджері", - "self_owner": "Иесі", - "admin": "Әкімші", - "worker": "Жұмысшы" + "staff": "Қызметкер" }, "pagination": { "showing": "Көрсетілген", @@ -287,16 +518,15 @@ "phonePlaceholder": "Телефон нөмірін енгізіңіз", "role": "Рөл", "rolePlaceholder": "Рөлді таңдаңыз", - "pointCode": "Нүкте коды", - "pointCodePlaceholder": "Нүкте кодын енгізіңіз", + "locationId": "Локация", + "locationIdPlaceholder": "Локацияны таңдаңыз", "submit": "Жұмысшы қосу", "submitting": "Қосуда...", "cancel": "Болдырмау", "success": "Жұмысшы сәтті қосылды. Тіркелгіні аяқтау үшін сізге телефон нөміріне сілтеме жіберілді.", "roles": { "staff": "Жұмысшы", - "manager": "Менеджер", - "net_manager": "Желі менеджері" + "manager": "Менеджер" }, "errors": { "nameMin": "Аты кемінде 2 таңбадан тұруы керек", @@ -306,15 +536,14 @@ "phoneRequired": "Телефон нөмірін енгізіңіз", "phoneInvalid": "Дұрыс емес телефон нөмірі", "roleRequired": "Рөлді таңдаңыз", - "pointCodeRequired": "Нүкте кодын енгізіңіз", - "pointCodeMax": "Нүкте коды кемінде 50 таңбадан тұруы керек", + "pointCodeRequired": "Локацияны таңдаңыз", "submitError": "Жұмысшы қосу қатесі" } } }, - "Points": { - "title": "Қызмет көрсету нүктелері", - "tableTitle": "Қызмет көрсету нүктелері", + "Locations": { + "title": "Қызмет көрсету локациялары", + "tableTitle": "Қызмет көрсету локациялары", "code": "Код", "name": "Атауы", "address": "Мекенжай", @@ -325,8 +554,65 @@ "noData": "Көрсету үшін деректер жоқ", "errorLoading": "Деректерді жүктеу қатесі", "accessDenied": "Қол жетімсіз", - "accessDeniedDescription": "Нүктелер тізімін көруге құқығыңыз жоқ", - "addPoint": "Нүкте қосу", + "accessDeniedDescription": "Локациялар тізімін көруге құқығыңыз жоқ", + "addPoint": "Локация қосу", + "detail": { + "edit": "Өңдеу", + "phone": "Телефон", + "scheduleType": "Кесте түрі", + "slotDuration": "Слот ұзақтығы", + "minutesShort": "мин", + "workingHours": "Жұмыс уақыты", + "closed": "Жабық", + "staffCount": "Қызметкерлер", + "servicesCount": "Қызметтер", + "bookingLink": "Жазылу сілтемесі", + "bookingLinkHint": "Бұл сілтемені клиенттермен бөлісіңіз", + "copyLink": "Көшіру", + "linkCopied": "Сілтеме көшірілді", + "qrCode": "QR", + "technicalId": "ID", + "createdAt": "Құрылған", + "backToAll": "Барлық локациялар", + "noSchedule": "Кесте баптаулмаған", + "scheduleTypes": { + "fixed": "Бекітілген", + "mixed": "Аралас" + }, + "delete": "Жою", + "deleteTitle": "Локацияны жою", + "deleteDescription": "Сіз \"{name}\" локациясын жойғыңыз келетініне сенімдісіз бе? Бұл әрекетті болдырмау мүмкін емес.", + "deleteConfirm": "Иә, жою", + "deleteCancel": "Болдырмау", + "deleteSuccess": "Локация жойылды", + "deleteError": "Локацияны жою кезінде қате" + }, + "editDialog": { + "title": "Локацияны өңдеу", + "description": "Локация туралы ақпаратты өзгертіңіз", + "save": "Сақтау", + "saving": "Сақтау...", + "success": "Локация жаңартылды", + "error": "Локацияны жаңарту кезінде қате" + }, + "network": { + "locationCount": "{count} локация", + "addLocation": "Локация қосу", + "staff": "қызм.", + "services": "қызмет" + }, + "toggleActive": { + "deactivateTitle": "Локацияны өшіру", + "deactivateDescription": "\"{name}\" өшіргіңіз келетініне сенімдісіз бе? Клиенттер бұл локацияға жазыла алмайды.", + "deactivateConfirm": "Өшіру", + "deactivateSuccess": "Локация өшірілді", + "activateTitle": "Локацияны қосу", + "activateDescription": "\"{name}\" қосу керек пе? Локация жазылу үшін қайтадан қолжетімді болады.", + "activateConfirm": "Қосу", + "activateSuccess": "Локация қосылды", + "cancel": "Болдырмау", + "error": "Күйді жаңарту кезінде қате" + }, "schedule": { "title": "Кесте", "allDay": "Күні бойы", @@ -335,14 +621,34 @@ "noSchedule": "Кесте көрсетілмеген" }, "addPointDialog": { - "title": "Жаңа нүкте қосу", - "description": "Қызмет көрсету нүктесі туралы ақпаратты толтырыңыз" + "title": "Жаңа локация қосу", + "description": "Қызмет көрсету локациясы туралы ақпаратты толтырыңыз" }, "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": "Мекенжайды таңдаңыз, қала және көше автоматты түрде толтырылады, бірақ сіз оларды өзгерте аласыз", @@ -355,35 +661,49 @@ "mapNoLocation": "Картада көрсету үшін мекенжайды таңдаңыз", "city": "Қала", "street": "Көше", - "latitude": "Ендік", - "longitude": "Бойлық" - }, - "schedule": { - "title": "Кесте", - "allDay": "Күні бойы", - "comment": "Түсініктеме" + "building": "Ғимарат / корпус", + "buildingPlaceholder": "Үй нөмірі, корпус", + "details": "Қосымша ақпарат", + "detailsPlaceholder": "Қабат, кеңсе, кіреберіс және т.б." }, "submit": "Құру", "submitting": "Құрылуда...", "cancel": "Болдырмау", - "success": "Нүкте сәтті құрылды", + "success": "Локация сәтті құрылды", "errors": { "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": "Локацияны құру кезінде қате" + } + }, + "organizationProfile": { + "title": "Желіге біріктіру керек пе?", + "description": "Сізде бірнеше локация бар — оларды желіге біріктіре аласыз. Бұл міндетті емес, бірақ барлық локацияларды бір бренд астында басқаруға көмектеседі.", + "name": "Желі атауы", + "namePlaceholder": "Мысалы: Beauty Network", + "orgDescription": "Сипаттама", + "orgDescriptionPlaceholder": "Желіңіздің қысқаша сипаттамасы", + "skip": "Өткізіп жіберу", + "submit": "Желі құру", + "submitting": "Құрылуда...", + "success": "Желі сәтті құрылды", + "errors": { + "nameMin": "Атау кемінде 2 таңбадан тұруы керек", + "nameMax": "Атау 100 таңбадан аспауы керек", + "descriptionMax": "Сипаттама 500 таңбадан аспауы керек", + "submitError": "Желіні құру кезінде қате" } } }, @@ -406,9 +726,9 @@ "status": "Күйі", "masters": "Шеберлер" }, - "pointSelect": { - "label": "Қызмет көрсету нүктесі", - "placeholder": "Нүктені таңдаңыз" + "locationSelect": { + "label": "Локация", + "placeholder": "Локацияны таңдаңыз" }, "addServiceDialog": { "title": "Қызмет қосу", @@ -454,10 +774,58 @@ "durationMustBePositive": "Ұзақтық оң сан болуы керек", "colorRequired": "Түсті таңдаңыз", "colorMax": "Түс 50 таңбадан аспауы керек", - "pointCodeRequired": "Нүкте кодын анықтау мүмкін болмады", + "pointCodeRequired": "Локацияны анықтау мүмкін болмады", "submitError": "Қызмет құру кезінде қате" } }, + "serviceCount": "{count} қызмет", + "drawer": { + "detailsTab": "Мәліметтер", + "staffTab": "Қызметкерлер", + "save": "Сақтау", + "saving": "Сақтауда...", + "saveSuccess": "Қызмет жаңартылды", + "saveError": "Жаңарту қатесі", + "deleteBtn": "Жою", + "sortOrder": "Сұрыптау реті" + }, + "actions": { + "edit": "Өңдеу", + "manageStaff": "Қызметкерлерді басқару", + "duplicate": "Көшірмелеу", + "delete": "Жою", + "duplicateSuccess": "Қызмет көшірмеленді", + "duplicateError": "Көшірмелеу қатесі" + }, + "deleteDialog": { + "title": "Қызметті жою", + "description": "\"{name}\" қызметін жойғыңыз келетініне сенімдісіз бе? Бұл әрекетті қайтару мүмкін емес.", + "cancel": "Болдырмау", + "confirm": "Жою", + "success": "Қызмет жойылды", + "error": "Жою қатесі" + }, + "staffTab": { + "assigned": "Тағайындалған", + "available": "Қолжетімді қызметкерлер", + "remove": "Алып тастау", + "add": "Қосу", + "assign": "+ Тағайындау", + "priceRange": "Баға диапазоны", + "duration": "Ұзақтығы", + "noStaff": "Қолжетімді қызметкерлер жоқ", + "notSet": "Көрсетілмеген", + "pricePlaceholder": "Баға", + "addSuccess": "Қызметкер қызметке байланыстырылды", + "addError": "Қызметкерді байланыстыру қатесі", + "unlinkTitle": "Байланысты жоюды растаңыз", + "unlinkDescription": "{name} — «{service}» қызметіне жазыла алмайды", + "unlinkConfirm": "Байланысты жою", + "unlinkCancel": "Болдырмау", + "unlinkSuccess": "Қызметкер қызметтен ажыратылды", + "unlinkError": "Ажырату қатесі", + "permissionsUpdated": "Қызметкердің құқықтары жаңартылды" + }, "attachMaster": { "dialogTitle": "Қызметке шеберді байланыстыру", "dialogDescription": "Қызметке шеберді байланыстыру: {serviceName}", @@ -480,5 +848,88 @@ "serviceIdRequired": "Қызмет ID міндетті", "masterPhoneRequired": "Шеберді таңдаңыз" } + }, + "Schedule": { + "title": "Жұмыс графигі", + "description": "Ай күндері бойынша жұмыс графигін баптау", + "noAccess": "Графикті баптауға қол жетімсіз", + "mySchedule": "Менің графигім", + "pointSchedule": "Локация графигі", + "monthGridTitle": "Ай күндері", + "editorTitle": "Таңдалған күндердің графигі", + "selectedCount": "Таңдалды: {count}", + "selectedDay": "Таңдалды: 1 күн — {day}", + "selectedDays": "Таңдалды: {count} күн", + "clearSelection": "Тазалау", + "shiftHint": "Ауқымды таңдау үшін Shift басып тұрыңыз", + "saveCount": "{count} күнді сақтау", + "dayOff": "Демалыс", + "customBadge": "Өзгертілген", + "fixedScheduleInfo": "Сіздің локация бекітілген графикті пайдаланады — барлығы бір кесте бойынша жұмыс істейді", + "Weekdays": { + "mon": "Дс", + "tue": "Сс", + "wed": "Ср", + "thu": "Бс", + "fri": "Жм", + "sat": "Сн", + "sun": "Жс" + }, + "MonthNav": { + "prev": "Алдыңғы ай", + "next": "Келесі ай", + "today": "Бүгін" + }, + "Header": { + "title": "Жұмыс графигі", + "mySchedule": "Менің графигім", + "pointSchedule": "Локация графигі", + "fixedBadge": "Бекітілген", + "mixedBadge": "Аралас", + "scheduleTypeFixedHint": "Локация графигі бекітілген, сіз оны өзгерте алмайсыз", + "scheduleTypeMixedHint": "Локация графигі аралас — шебер әр айға өз графигін өзі орнатады", + "selectLocation": "Локацияны таңдаңыз" + }, + "save": "Сақтау", + "Editor": { + "selectDaysHint": "Графикті орнату үшін күнтізбеден күндерді таңдаңыз", + "dayIsClosed": "Күн демалыс деп белгіленген", + "makeOpen": "Жұмыс күні ету", + "makeClosed": "Демалыс күні ету", + "open": "Ашық", + "closed": "Жабық", + "workHours": "Жұмыс сағаттары", + "breaks": "Үзілістер", + "noBreaks": "Үзілістер жоқ", + "from": "Бастап", + "to": "Дейін", + "addBreak": "Үзіліс қосу", + "addWorkRange": "Интервал қосу", + "mixedTimesWarning": "Таңдалған күндердің уақыты әртүрлі. Өзгерістер барлық таңдалған күндерге қолданылады.", + "endBeforeStart": "Аяқталу уақыты басталу уақытынан кеш болуы керек", + "overlappingRanges": "Жұмыс интервалдары қиылыспауы керек", + "breakOutsideWork": "Үзіліс жұмыс уақыты ішінде болуы керек", + "overlappingBreaks": "Үзілістер қиылыспауы керек" + }, + "savedSuccess": "Кесте сақталды", + "savedError": "Кестені сақтау қатесі", + "dayOffSuccess": "Демалыс сақталды", + "Presets": { + "allWeekdays": "Барлық жұмыс күндері", + "weekdays": "5/2", + "evenDays": "Жұп күндер", + "oddDays": "Тақ күндер", + "clear": "Тазалау" + } + }, + "apiErrors": { + "permission_denied": "Бұл әрекетті орындауға рұқсат жоқ", + "not_found": "Жазба табылмады", + "conflict": "Деректер қақтығысы, қайталап көріңіз", + "validation_error": "Деректерді тексеру қатесі", + "unauthorized": "Сессия аяқталды, қайта кіріңіз", + "rate_limit": "Сұраныстар тым көп, күте тұрыңыз", + "internal_error": "Сервердің ішкі қатесі", + "unknown": "Қате орын алды, кейінірек қайталаңыз" } } diff --git a/messages/ru.json b/messages/ru.json index 1004aef..bbff8e2 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1,17 +1,26 @@ { "LocaleLayout": { "title": "Bagsy — управление записями" }, + "Offline": { + "title": "Нет подключения", + "description": "Проверьте интернет-соединение. Страница откроется автоматически, когда связь восстановится.", + "retry": "Повторить попытку" + }, "Sidebar": { "sidebar": "Боковое меню", "sidebarDescription": "Отображает боковое меню.", "Main": { "calendar": "Календарь", + "schedule": "Расписание", "clients": "Клиенты", "services": "Услуги", "staff": "Сотрудники", - "points": "Точки", + "locations": "Локации", "analytics": "Аналитика", "settings": "Настройки" }, + "LocationSelect": { + "label": "Локация" + }, "User": { "name": "Имя", "phone": "Телефон", @@ -19,13 +28,20 @@ "logout": "Выйти" } }, + "PwaInstallPrompt": { + "add-to-home-screen": "Добавить на главный экран", + "share": "Поделиться", + "to-home-screen": "На экран \"Домой\"", + "install-app": "Установить приложение", + "install": "Установить" + }, "Dashboard": { "content": { "calendar": "Календарь" }, "title": { "welcome": "Добро пожаловать!", "calendar": "Календарь" }, - "emptyPoints": { + "emptyLocations": { "title": "Нет точек обслуживания", "description": "У вас пока нет точек обслуживания. Добавьте первую точку, чтобы начать работу с календарем.", "addPointButton": "Добавить точку" @@ -103,14 +119,35 @@ "comment": "Комментарий", "commentDescription": "Дополнительная информация", "add": "Добавить запись", - "cancel": "Отмена" + "notLinkedService": "Не привязана", + "notLinkedMaster": "Не привязан", + "cancel": "Отмена", + "errors": { + "firstNameRequired": "Введите имя", + "lastNameRequired": "Введите фамилию", + "phoneRequired": "Введите телефон", + "serviceRequired": "Выберите услугу", + "startDateRequired": "Выберите дату начала", + "selectStaff": "Выберите работника", + "selectLocation": "Выберите точку", + "selectLocationFirst": "Сначала выберите точку" + } }, "EventDetailsDialog": { "edit": "Редактировать", + "cancel": "Отменить запись", + "cancelConfirm": "Вы уверены, что хотите отменить эту запись?", + "cancelReason": "Причина отмены", + "cancelReasonPlaceholder": "Укажите причину (необязательно)", + "cancelSuccess": "Запись отменена", + "cancelError": "Не удалось отменить запись", + "cancelling": "Отмена...", "responsible": "Ответственный", "startDate": "Дата и время начала", "endDate": "Дата и время окончания", - "comment": "Комментарий" + "comment": "Комментарий", + "client": "Клиент", + "status": "Статус" }, "DayCell": { "more": "ещё" @@ -202,8 +239,8 @@ "active": "Активен", "role": "Роль", "inactive": "Неактивен", - "pointCode": "Код точки", - "networkCode": "Код сети", + "locationId": "Локация", + "networkCode": "Организация", "createdAt": "Дата создания", "updatedAt": "Последнее обновление", "editProfile": "Редактировать профиль", @@ -224,13 +261,155 @@ "errorRemovingAvatar": "Ошибка при удалении аватара" } }, + "Account": { + "title": "Аккаунт", + "tabs": { + "profile": "Профиль", + "subscription": "Подписка", + "security": "Безопасность", + "appearance": "Внешний вид" + }, + "Profile": { + "changePhoto": "Изменить фото", + "removePhoto": "Удалить фото", + "personalInfo": "Личная информация", + "fullName": "Полное имя", + "firstName": "Имя", + "lastName": "Фамилия", + "phone": "Телефон", + "email": "Email", + "notSet": "Не указан", + "role": "Роль", + "edit": "Изменить", + "add": "Добавить", + "save": "Сохранить", + "cancel": "Отмена", + "business": "Бизнес", + "businessName": "Название бизнеса", + "location": "Локация", + "organizationId": "Организация", + "copy": "Скопировать", + "сopied": "Скопировано", + "account": "Аккаунт", + "createdAt": "Создан", + "updatedAt": "Обновлён", + "deleteAccount": "Удалить аккаунт", + "deleteAccountDescription": "Это действие необратимо. Все данные будут удалены.", + "nameMinError": "Имя должно содержать минимум 2 символа", + "surnameMinError": "Фамилия должна содержать минимум 2 символа", + "profileUpdatedSuccessfully": "Профиль успешно обновлён", + "errorUpdatingProfile": "Ошибка при обновлении профиля", + "uploadError": "Ошибка загрузки фото", + "avatarRemovedSuccess": "Аватар удалён", + "errorRemovingAvatar": "Ошибка при удалении аватара" + }, + "Subscription": { + "currentPlan": "Текущий план", + "active": "Активен", + "trial": "Пробный", + "expired": "Истёк", + "cancelled": "Отменён", + "nextPayment": "Следующий платёж", + "usedOfMax": "{used} из {max}", + "amount": "Сумма", + "bookings": "Записи", + "staffSlots": "Мастера", + "locations": "Точки", + "unlimited": "∞", + "upgradePlan": "Улучшить план", + "comparePlans": "Сравнить планы", + "manualPaymentWarning": "Оплата пока производится вручную. Для продления свяжитесь с нами через", + "whatsApp": "WhatsApp", + "paymentHistory": "История платежей", + "date": "Дата", + "description": "Описание", + "status": "Статус", + "paid": "Оплачен", + "noPayments": "Платежей пока нет", + "paymentMethod": "Способ оплаты", + "noPaymentMethod": "Способ оплаты не привязан", + "addCard": "Привязать карту", + "perMonth": "/ мес", + "popular": "Популярный", + "trialMonths": "Первые {count} мес. бесплатно", + "trialMonth": "Первый месяц бесплатно", + "plans": { + "solo": { + "name": "Solo", + "description": "Для самозанятых мастеров", + "locations": "1 локация", + "staff": "1 мастер" + }, + "point": { + "name": "Point", + "description": "Для небольших салонов", + "locations": "1 локация", + "staff": "до 10 мастеров" + }, + "network": { + "name": "Network", + "description": "Для сетей салонов", + "locations": "Неограниченно точек", + "staff": "Неограниченно мастеров" + } + }, + "features": { + "unlimitedBookings": "Неограниченные записи", + "basicCrm": "CRM базовая", + "notifications": "Уведомления (SMS / WhatsApp / Email)", + "onlineBooking": "Онлайн-запись без рекламы", + "reminders": "Напоминания о записях", + "allFromSolo": "Всё из Solo", + "advancedCrm": "Расширенная CRM", + "analytics": "Аналитика и отчёты", + "finance": "Финансовый учёт", + "inventory": "Складской учёт", + "allFromPoint": "Всё из Point", + "multiBranch": "Мультифилиальность", + "consolidatedAnalytics": "Сводная аналитика по всем локациям", + "apiAccess": "API доступ", + "inDevelopment": "в разработке", + "planned": "в планах" + } + }, + "Security": { + "password": "Пароль", + "lastChanged": "Последнее изменение", + "never": "никогда", + "changePassword": "Сменить пароль", + "activeSessions": "Активные сессии", + "currentSession": "текущая сессия", + "revoke": "Отозвать", + "logoutAll": "Выйти со всех устройств", + "daysAgo": "{count} дн. назад" + }, + "Appearance": { + "theme": "Тема", + "light": "Светлая", + "dark": "Тёмная", + "system": "Системная", + "language": "Язык", + "russian": "Русский", + "kazakh": "Қазақша", + "notifications": "Уведомления", + "smsReminders": "SMS-напоминания клиентам", + "smsRemindersDescription": "Отправлять за 2ч до записи", + "dailyDigest": "Дайджест на email", + "dailyDigestDescription": "Сводка записей на завтра", + "notSupported": "Ваш браузер не поддерживает уведомления", + "blocked": "Уведомления заблокированы. Разрешите их в настройках браузера", + "pushNotifications": "Push-уведомления", + "pushDescription": "Получать уведомления о новых записях", + "calendar": "Календарь" + } + }, "Staff": { "title": "Сотрудники", "tableTitle": "Сотрудники", "addStaff": "Добавить сотрудника", "searchPlaceholder": "Поиск по имени, телефону, роли...", "networkCode": "Код сети", - "pointCode": "Код точки", + "locationId": "Локация", "role": "Роль", "name": "Имя", "surname": "Фамилия", @@ -248,26 +427,78 @@ "accessDeniedDescription": "У вас нет прав для просмотра списка сотрудников", "errorLoadingData": "Ошибка загрузки данных", "unknownError": "Неизвестная ошибка", + "invite": "Пригласить", + "employeesCount": "сотрудников", + "staffLimit": "лимит сотрудников", "filters": { - "pointCode": "Код точки", - "pointCodePlaceholder": "Введите код точки", - "networkCode": "Код сети", + "searchByNameOrPhone": "Поиск по имени или телефону...", + "allRoles": "Все роли", + "allStatuses": "Все статусы", + "allLocations": "Все точки", + "active": "Активные", + "inactive": "Неактивные", + "locationId": "Локация", + "locationIdPlaceholder": "Введите UUID локации", + "networkCode": "Организация", "networkCodePlaceholder": "Введите код сети", "role": "Роль", "rolePlaceholder": "Выберите роль", - "allRoles": "Все роли", - "phone": "Телефон", - "phonePlaceholder": "Введите телефон", + "search": "Поиск", + "searchPlaceholder": "Введите тел. или фио", "clear": "Очистить", "phones": "Телефоны" }, + "drawer": { + "profile": "Профиль", + "services": "Услуги", + "portfolio": "Портфолио", + "info": "Информация", + "phone": "Телефон", + "location": "Локация", + "status": "Статус", + "joined": "Дата входа", + "permissions": "Права доступа", + "canProvideServices": "Оказывает услуги", + "canProvideServicesDesc": "Может принимать клиентов", + "canManageSchedule": "Управление расписанием", + "canManageScheduleDesc": "Редактирование рабочих часов точки", + "quickStats": "Статистика", + "servicesCount": "услуг", + "thisWeek": "за неделю", + "rating": "рейтинг", + "actions": "Действия", + "changeRole": "Изменить роль", + "transfer": "Перевести на точку", + "deactivate": "Не активен", + "activate": "Активен", + "totalServices": "Всего услуг", + "priceRange": "Диапазон цен", + "min": "мин", + "noServices": "Нет привязанных услуг", + "portfolioPlaceholder": "Портфолио будет доступно в ближайшее время", + "viewProfile": "Посмотреть профиль", + "changeRoleTitle": "Изменить роль", + "changeRoleDesc": "Выберите новую роль для сотрудника", + "transferTitle": "Перевод на другую точку", + "transferDesc": "Выберите локацию для перевода", + "selectRole": "Выберите роль", + "selectLocation": "Выберите локацию", + "save": "Сохранить", + "cancel": "Отмена", + "deactivateTitle": "Деактивация сотрудника", + "deactivateDesc": "Вы уверены что хотите деактивировать сотрудника? К нему не смогут записаться.", + "activateTitle": "Активация сотрудника", + "activateDesc": "Вы уверены что хотите активировать сотрудника? К нему смогут записаться.", + "confirm": "Подтвердить", + "cannotDeactivateSelf": "Нельзя деактивировать себя", + "detach": "Отвязать от точки", + "detachTitle": "Отвязка от точки", + "detachDesc": "{name} будет отвязан от текущей локации. Все записи к этому сотруднику будут отменены." + }, "roles": { - "staff": "Сотрудник", + "owner": "Владелец", "manager": "Менеджер", - "net_manager": "Сетевой менеджер", - "self_owner": "Владелец", - "admin": "Администратор", - "worker": "Работник" + "staff": "Сотрудник" }, "pagination": { "showing": "Показано", @@ -287,16 +518,15 @@ "phonePlaceholder": "Введите номер телефона", "role": "Роль", "rolePlaceholder": "Выберите роль", - "pointCode": "Код точки", - "pointCodePlaceholder": "Введите код точки", + "locationId": "Локация", + "locationIdPlaceholder": "Выберите локацию", "submit": "Добавить сотрудника", "submitting": "Добавление...", "cancel": "Отмена", "success": "Сотрудник успешно добавлен. Ссылка для завершения регистрации отправлена.", "roles": { "staff": "Сотрудник", - "manager": "Менеджер", - "net_manager": "Сетевой менеджер" + "manager": "Менеджер" }, "errors": { "nameMin": "Имя должно содержать минимум 2 символа", @@ -306,15 +536,14 @@ "phoneRequired": "Введите номер телефона", "phoneInvalid": "Некорректный номер телефона", "roleRequired": "Выберите роль", - "pointCodeRequired": "Введите код точки", - "pointCodeMax": "Код точки не должен превышать 50 символов", + "pointCodeRequired": "Выберите локацию", "submitError": "Ошибка при добавлении сотрудника" } } }, - "Points": { - "title": "Точки обслуживания", - "tableTitle": "Точки обслуживания", + "Locations": { + "title": "Локации", + "tableTitle": "Локации", "code": "Код", "name": "Название", "address": "Адрес", @@ -327,6 +556,63 @@ "accessDenied": "Доступ запрещен", "accessDeniedDescription": "У вас нет прав для просмотра списка точек", "addPoint": "Добавить точку", + "detail": { + "edit": "Редактировать", + "phone": "Телефон", + "scheduleType": "Тип расписания", + "slotDuration": "Длительность слота", + "minutesShort": "мин", + "workingHours": "Часы работы", + "closed": "Закрыто", + "staffCount": "Сотрудники", + "servicesCount": "Услуги", + "bookingLink": "Ссылка для записи", + "bookingLinkHint": "Поделитесь этой ссылкой с клиентами", + "copyLink": "Копировать", + "linkCopied": "Ссылка скопирована", + "qrCode": "QR", + "technicalId": "ID", + "createdAt": "Создано", + "backToAll": "Все локации", + "noSchedule": "Расписание не настроено", + "scheduleTypes": { + "fixed": "Фиксированное", + "mixed": "Смешанное" + }, + "delete": "Удалить", + "deleteTitle": "Удалить локацию", + "deleteDescription": "Вы уверены, что хотите удалить локацию \"{name}\"? Это действие нельзя отменить.", + "deleteConfirm": "Да, удалить", + "deleteCancel": "Отмена", + "deleteSuccess": "Локация удалена", + "deleteError": "Ошибка при удалении локации" + }, + "editDialog": { + "title": "Редактировать локацию", + "description": "Измените информацию о локации", + "save": "Сохранить", + "saving": "Сохранение...", + "success": "Локация обновлена", + "error": "Ошибка при обновлении локации" + }, + "network": { + "locationCount": "{count} локаций", + "addLocation": "Добавить локацию", + "staff": "сотр.", + "services": "услуг" + }, + "toggleActive": { + "deactivateTitle": "Деактивировать локацию", + "deactivateDescription": "Вы уверены, что хотите деактивировать \"{name}\"? Клиенты не смогут записываться в эту точку.", + "deactivateConfirm": "Деактивировать", + "deactivateSuccess": "Локация деактивирована", + "activateTitle": "Активировать локацию", + "activateDescription": "Активировать \"{name}\"? Локация снова станет доступна для записи.", + "activateConfirm": "Активировать", + "activateSuccess": "Локация активирована", + "cancel": "Отмена", + "error": "Ошибка при обновлении статуса" + }, "schedule": { "title": "Расписание", "allDay": "Весь день", @@ -341,8 +627,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": "Город и улица заполнятся автоматически при выборе адреса из поиска, но вы можете их изменить вручную", @@ -355,35 +661,49 @@ "mapNoLocation": "Выберите адрес для отображения на карте", "city": "Город", "street": "Улица", - "latitude": "Широта", - "longitude": "Долгота" - }, - "schedule": { - "title": "Расписание", - "allDay": "Весь день", - "comment": "Комментарий" + "building": "Здание / корпус", + "buildingPlaceholder": "Номер дома, корпус", + "details": "Доп. информация", + "detailsPlaceholder": "Этаж, офис, вход и т.д." }, "submit": "Создать", "submitting": "Создание...", "cancel": "Отмена", - "success": "Точка успешно создана", + "success": "Локация успешно создана", "errors": { "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": "Ошибка при создании локации" + } + }, + "organizationProfile": { + "title": "Объединить в сеть?", + "description": "У вас несколько точек — вы можете объединить их в сеть. Это необязательно, но поможет управлять всеми локациями под одним брендом.", + "name": "Название сети", + "namePlaceholder": "Например: Beauty Network", + "orgDescription": "Описание", + "orgDescriptionPlaceholder": "Краткое описание вашей сети", + "skip": "Пропустить", + "submit": "Создать сеть", + "submitting": "Создание...", + "success": "Сеть успешно создана", + "errors": { + "nameMin": "Название должно содержать минимум 2 символа", + "nameMax": "Название не должно превышать 100 символов", + "descriptionMax": "Описание не должно превышать 500 символов", + "submitError": "Ошибка при создании сети" } } }, @@ -406,9 +726,9 @@ "status": "Статус", "masters": "Мастера" }, - "pointSelect": { - "label": "Точка обслуживания", - "placeholder": "Выберите точку" + "locationSelect": { + "label": "Локация", + "placeholder": "Выберите локацию" }, "addServiceDialog": { "title": "Добавить услугу", @@ -454,10 +774,58 @@ "durationMustBePositive": "Длительность должна быть положительным числом", "colorRequired": "Выберите цвет", "colorMax": "Цвет не должен превышать 50 символов", - "pointCodeRequired": "Не удалось определить код точки", + "pointCodeRequired": "Не удалось определить локацию", "submitError": "Ошибка при создании услуги" } }, + "serviceCount": "{count} услуг", + "drawer": { + "detailsTab": "Детали", + "staffTab": "Сотрудники", + "save": "Сохранить", + "saving": "Сохранение...", + "saveSuccess": "Услуга обновлена", + "saveError": "Ошибка обновления", + "deleteBtn": "Удалить", + "sortOrder": "Порядок сортировки" + }, + "actions": { + "edit": "Редактировать", + "manageStaff": "Управление сотрудниками", + "duplicate": "Дублировать", + "delete": "Удалить", + "duplicateSuccess": "Услуга дублирована", + "duplicateError": "Ошибка дублирования" + }, + "deleteDialog": { + "title": "Удалить услугу", + "description": "Вы уверены, что хотите удалить услугу \"{name}\"? Это действие необратимо.", + "cancel": "Отмена", + "confirm": "Удалить", + "success": "Услуга удалена", + "error": "Ошибка удаления" + }, + "staffTab": { + "assigned": "Назначенные", + "available": "Доступные сотрудники", + "remove": "Отвязать", + "add": "Добавить", + "assign": "Назначить", + "priceRange": "Диапазон цен", + "duration": "Длительность", + "noStaff": "Нет доступных сотрудников", + "notSet": "Не указано", + "pricePlaceholder": "Цена", + "addSuccess": "Сотрудник привязан к услуге", + "addError": "Ошибка привязки сотрудника", + "unlinkTitle": "Подтвердите отвязку", + "unlinkDescription": "К {name} не смогут больше записаться на «{service}»", + "unlinkConfirm": "Отвязать", + "unlinkCancel": "Отмена", + "unlinkSuccess": "Сотрудник отвязан от услуги", + "unlinkError": "Ошибка отвязки", + "permissionsUpdated": "Разрешения обновлены" + }, "attachMaster": { "dialogTitle": "Привязать мастера к услуге", "dialogDescription": "Привязка мастера к услуге: {serviceName}", @@ -480,5 +848,88 @@ "serviceIdRequired": "ID услуги обязателен", "masterPhoneRequired": "Выберите мастера" } + }, + "Schedule": { + "title": "График", + "description": "Настройка графика работы по дням месяца", + "noAccess": "Нет доступа к настройке графика", + "mySchedule": "Мой график", + "pointSchedule": "График точки", + "monthGridTitle": "Дни месяца", + "editorTitle": "График выбранных дней", + "selectedCount": "Выбрано: {count}", + "selectedDay": "Выбрано: 1 день — {day}", + "selectedDays": "Выбрано: {count} дн.", + "clearSelection": "Сбросить", + "shiftHint": "Удерживайте Shift для выбора диапазона", + "saveCount": "Сохранить {count} дн.", + "dayOff": "Выходной", + "customBadge": "Своё", + "fixedScheduleInfo": "Ваша локация использует фиксированный график — все работают по одному расписанию", + "Weekdays": { + "mon": "Пн", + "tue": "Вт", + "wed": "Ср", + "thu": "Чт", + "fri": "Пт", + "sat": "Сб", + "sun": "Вс" + }, + "MonthNav": { + "prev": "Предыдущий месяц", + "next": "Следующий месяц", + "today": "Сегодня" + }, + "Header": { + "title": "График", + "mySchedule": "Мой график", + "pointSchedule": "График точки", + "fixedBadge": "Фиксированный", + "mixedBadge": "Смешанный", + "scheduleTypeFixedHint": "График точки фиксированный, вы не можете его менять", + "scheduleTypeMixedHint": "График точки смешанный — мастер настраивает свой график на каждый месяц", + "selectLocation": "Выберите точку" + }, + "save": "Сохранить", + "Editor": { + "selectDaysHint": "Выберите дни в календаре, чтобы задать график", + "dayIsClosed": "День отмечен как выходной", + "makeOpen": "Сделать рабочим", + "makeClosed": "Сделать выходным", + "open": "Открыто", + "closed": "Закрыто", + "workHours": "Рабочие часы", + "breaks": "Перерывы", + "noBreaks": "Нет перерывов", + "from": "С", + "to": "До", + "addBreak": "Добавить перерыв", + "addWorkRange": "Добавить интервал", + "mixedTimesWarning": "У выбранных дней разное время. Изменения будут применены ко всем выбранным дням.", + "endBeforeStart": "Время окончания должно быть позже начала", + "overlappingRanges": "Рабочие интервалы не должны пересекаться", + "breakOutsideWork": "Перерыв должен быть внутри рабочих часов", + "overlappingBreaks": "Перерывы не должны пересекаться" + }, + "savedSuccess": "Расписание сохранено", + "savedError": "Ошибка сохранения расписания", + "dayOffSuccess": "Выходной сохранён", + "Presets": { + "allWeekdays": "Все будни", + "weekdays": "5/2", + "evenDays": "Чётные", + "oddDays": "Нечётные", + "clear": "Очистить" + } + }, + "apiErrors": { + "permission_denied": "Нет прав для выполнения этого действия", + "not_found": "Запись не найдена", + "conflict": "Конфликт данных, попробуйте ещё раз", + "validation_error": "Ошибка валидации данных", + "unauthorized": "Сессия истекла, войдите заново", + "rate_limit": "Слишком много запросов, подождите", + "internal_error": "Внутренняя ошибка сервера", + "unknown": "Произошла ошибка, попробуйте позже" } } diff --git a/next.config.ts b/next.config.ts index 1f9ca5d..aed9ea1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,14 +1,57 @@ 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"); +const locales = ["ru", "kz"]; + +// Ревизия для 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: locales.map(locale => ({ + url: `/${locale}/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..0a7031b 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", @@ -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", @@ -60,6 +61,7 @@ "react-dom": "^19.2.3", "react-hook-form": "^7.63.0", "react-leaflet": "^5.0.0", + "react-qrcode-logo": "^4.0.0", "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", @@ -82,6 +84,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 0000000..38f799a Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000..461a32c Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/src/entities/drawer.tsx b/src/entities/drawer.tsx new file mode 100644 index 0000000..d24f6a0 --- /dev/null +++ b/src/entities/drawer.tsx @@ -0,0 +1,123 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { cn } from "@/src/shared/utils/styles"; + +/* Root */ +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; +const DrawerPortal = DrawerPrimitive.Portal; +const DrawerClose = DrawerPrimitive.Close; + +/* Overlay */ +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = "DrawerOverlay"; + +/* Content */ +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {/* Handle bar */} +
+ {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +/* Header */ +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = "DrawerHeader"; + +/* Footer */ +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = "DrawerFooter"; + +/* Title */ +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = "DrawerTitle"; + +/* Description */ +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = "DrawerDescription"; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/src/entities/index.ts b/src/entities/index.ts index 46941d9..9a67d74 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -14,6 +14,7 @@ export * from "./card"; export * from "./collapsible"; export * from "./dashboard-title"; export * from "./dialog"; +export * from "./drawer"; export * from "./form"; export * from "./header-title"; export * from "./input"; 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<