From 49e60d48c15a2a788409cfdb4d24cfd48c9c2280 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Apr 2026 16:32:18 +0300 Subject: [PATCH] feat: complete delivery workflow updates --- .env.example | 15 + QWEN.md | 184 +++++ docker-compose.yml | 658 ++++++++++++++++ docs/architecture.md | 40 +- docs/integrations/delivery-orchestration.md | 245 ++++++ docs/scenarios.md | 81 +- docs/superpowers/README.md | 103 +++ .../plans/2026-03-15-mobile-wave-1.md | 126 +++ .../2026-03-15-role-aware-orders-kanban.md | 190 +++++ ...6-03-30-delivery-platform-contract-plan.md | 269 +++++++ .../plans/2026-04-09-email-otp-auth.md | 253 ++++++ ...2-delivery-orchestration-implementation.md | 289 +++++++ ...026-04-13-1c-delivery-frontend-supabase.md | 283 +++++++ .../plans/2026-04-13-stage-1-supabase-demo.md | 347 +++++++++ .../superpowers/skills/brainstorming/SKILL.md | 99 +++ .../skills/executing-plans/SKILL.md | 67 ++ .../finishing-a-development-branch/SKILL.md | 167 ++++ .../skills/requesting-code-review/SKILL.md | 112 +++ .../subagent-driven-development/SKILL.md | 138 ++++ .../skills/systematic-debugging/SKILL.md | 119 +++ .../skills/test-driven-development/SKILL.md | 147 ++++ .../skills/using-git-worktrees/SKILL.md | 111 +++ .../verification-before-completion/SKILL.md | 74 ++ .../superpowers/skills/writing-plans/SKILL.md | 134 ++++ .../specs/2026-03-15-mobile-wave-1-design.md | 160 ++++ ...6-03-15-role-aware-orders-kanban-design.md | 179 +++++ .../specs/2026-04-13-1c-delivery-ui-design.md | 76 ++ src/components/admin/UserDirectoryPanel.jsx | 14 +- src/components/auth/OtpLoginForm.jsx | 6 + src/components/auth/OtpLoginForm.test.jsx | 18 +- src/components/client/DeliveryChoiceFlow.jsx | 64 ++ .../client/DeliveryChoiceFlow.test.jsx | 78 ++ src/components/client/DeliverySlotsPicker.jsx | 71 ++ .../client/DeliverySlotsPicker.test.jsx | 48 ++ src/components/client/DeliveryStateNotice.jsx | 41 + .../dashboard/RoleWorkspacePanel.jsx | 49 +- .../driver/DriverDeliveryDetail.jsx | 27 +- .../logistics/DeliverySetDetailPanel.jsx | 132 ++++ .../logistics/DeliverySetDetailPanel.test.jsx | 30 + .../logistics/LogisticsReadinessBoard.jsx | 116 +++ .../LogisticsReadinessBoard.test.jsx | 24 + src/components/orders/OrderDetailPanel.jsx | 12 +- .../orders/OrderDetailPanel.test.jsx | 102 +++ .../orders/OrderEditorPanel.test.jsx | 22 + src/components/orders/OrderFilters.jsx | 8 +- src/components/orders/OrderFilters.test.jsx | 34 + .../orders/OrdersCalendarView.test.jsx | 27 + src/components/orders/OrdersKanbanBoard.jsx | 206 +++++ .../orders/OrdersKanbanBoard.test.jsx | 86 ++ src/components/orders/OrdersTable.jsx | 9 +- src/components/orders/OrdersTable.test.jsx | 35 + src/components/orders/ordersKanbanDrag.js | 14 + .../orders/ordersKanbanDrag.test.js | 36 + .../deliveryWorkflow.contract.test.js | 76 ++ src/constants/deliveryWorkflow.js | 66 +- src/constants/roles.js | 20 +- src/context/AuthContext.jsx | 31 +- src/context/AuthContext.test.js | 21 + src/layouts/AppShell.test.jsx | 36 + src/pages/ClientDeliveryPage.jsx | 211 +++++ src/services/deliveryInvitationApi.js | 60 ++ src/services/deliveryInvitationApi.test.js | 160 ++++ src/services/deliverySetViews.js | 168 ++++ src/services/deliverySetViews.test.js | 169 ++++ src/services/orderViews.test.js | 9 +- src/services/supabase/orderRepository.js | 204 ++++- src/services/supabase/orderRepository.test.js | 124 ++- src/services/supabase/userRepository.js | 43 + src/services/supabase/userRepository.test.js | 23 + src/utils/formatters.test.js | 14 + .../_shared/delivery-invitations.test.ts | 35 + .../functions/_shared/delivery-invitations.ts | 110 +++ .../functions/_shared/integration-events.ts | 30 + .../confirm-delivery-choice/index.ts | 197 +++++ .../create-delivery-invitation/index.ts | 195 +++++ .../get-delivery-invitation/index.ts | 125 +++ .../functions/report-delivery-result/index.ts | 149 ++++ .../functions/transfer-to-logistics/index.ts | 145 ++++ supabase/schema.sql | 66 +- supabase/seed/stage-1-demo.sql | 732 ++++++++++++++++++ 80 files changed, 9053 insertions(+), 141 deletions(-) create mode 100644 QWEN.md create mode 100644 docker-compose.yml create mode 100644 docs/integrations/delivery-orchestration.md create mode 100644 docs/superpowers/README.md create mode 100644 docs/superpowers/plans/2026-03-15-mobile-wave-1.md create mode 100644 docs/superpowers/plans/2026-03-15-role-aware-orders-kanban.md create mode 100644 docs/superpowers/plans/2026-03-30-delivery-platform-contract-plan.md create mode 100644 docs/superpowers/plans/2026-04-09-email-otp-auth.md create mode 100644 docs/superpowers/plans/2026-04-12-delivery-orchestration-implementation.md create mode 100644 docs/superpowers/plans/2026-04-13-1c-delivery-frontend-supabase.md create mode 100644 docs/superpowers/plans/2026-04-13-stage-1-supabase-demo.md create mode 100644 docs/superpowers/skills/brainstorming/SKILL.md create mode 100644 docs/superpowers/skills/executing-plans/SKILL.md create mode 100644 docs/superpowers/skills/finishing-a-development-branch/SKILL.md create mode 100644 docs/superpowers/skills/requesting-code-review/SKILL.md create mode 100644 docs/superpowers/skills/subagent-driven-development/SKILL.md create mode 100644 docs/superpowers/skills/systematic-debugging/SKILL.md create mode 100644 docs/superpowers/skills/test-driven-development/SKILL.md create mode 100644 docs/superpowers/skills/using-git-worktrees/SKILL.md create mode 100644 docs/superpowers/skills/verification-before-completion/SKILL.md create mode 100644 docs/superpowers/skills/writing-plans/SKILL.md create mode 100644 docs/superpowers/specs/2026-03-15-mobile-wave-1-design.md create mode 100644 docs/superpowers/specs/2026-03-15-role-aware-orders-kanban-design.md create mode 100644 docs/superpowers/specs/2026-04-13-1c-delivery-ui-design.md create mode 100644 src/components/client/DeliveryChoiceFlow.jsx create mode 100644 src/components/client/DeliveryChoiceFlow.test.jsx create mode 100644 src/components/client/DeliverySlotsPicker.jsx create mode 100644 src/components/client/DeliverySlotsPicker.test.jsx create mode 100644 src/components/client/DeliveryStateNotice.jsx create mode 100644 src/components/logistics/DeliverySetDetailPanel.jsx create mode 100644 src/components/logistics/DeliverySetDetailPanel.test.jsx create mode 100644 src/components/logistics/LogisticsReadinessBoard.jsx create mode 100644 src/components/logistics/LogisticsReadinessBoard.test.jsx create mode 100644 src/components/orders/OrderDetailPanel.test.jsx create mode 100644 src/components/orders/OrderEditorPanel.test.jsx create mode 100644 src/components/orders/OrderFilters.test.jsx create mode 100644 src/components/orders/OrdersCalendarView.test.jsx create mode 100644 src/components/orders/OrdersKanbanBoard.jsx create mode 100644 src/components/orders/OrdersKanbanBoard.test.jsx create mode 100644 src/components/orders/OrdersTable.test.jsx create mode 100644 src/components/orders/ordersKanbanDrag.js create mode 100644 src/components/orders/ordersKanbanDrag.test.js create mode 100644 src/constants/deliveryWorkflow.contract.test.js create mode 100644 src/layouts/AppShell.test.jsx create mode 100644 src/pages/ClientDeliveryPage.jsx create mode 100644 src/services/deliveryInvitationApi.js create mode 100644 src/services/deliveryInvitationApi.test.js create mode 100644 src/services/deliverySetViews.js create mode 100644 src/services/deliverySetViews.test.js create mode 100644 src/services/supabase/userRepository.js create mode 100644 src/services/supabase/userRepository.test.js create mode 100644 src/utils/formatters.test.js create mode 100644 supabase/functions/_shared/delivery-invitations.test.ts create mode 100644 supabase/functions/_shared/delivery-invitations.ts create mode 100644 supabase/functions/_shared/integration-events.ts create mode 100644 supabase/functions/confirm-delivery-choice/index.ts create mode 100644 supabase/functions/create-delivery-invitation/index.ts create mode 100644 supabase/functions/get-delivery-invitation/index.ts create mode 100644 supabase/functions/report-delivery-result/index.ts create mode 100644 supabase/functions/transfer-to-logistics/index.ts create mode 100644 supabase/seed/stage-1-demo.sql diff --git a/.env.example b/.env.example index eec9922..cfdada4 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,17 @@ VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key + +# Self-hosted Supabase auth +SUPABASE_PUBLIC_URL=https://supa.example.com +API_EXTERNAL_URL=https://supa.example.com +SITE_URL=https://app.example.com +DISABLE_SIGNUP=true +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +ENABLE_ANONYMOUS_USERS=false +SMTP_ADMIN_EMAIL=delivery@example.com +SMTP_HOST=mail.example.com +SMTP_PORT=587 +SMTP_USER=delivery@example.com +SMTP_PASS=replace-me +SMTP_SENDER_NAME=SuperSam diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 0000000..a461421 --- /dev/null +++ b/QWEN.md @@ -0,0 +1,184 @@ +# Construction Delivery Control — QWEN Context + +## Project Overview + +**Construction Delivery Control** is a React-based single-page application for managing construction orders across the full lifecycle: order creation, production, logistics, and delivery coordination. It integrates with chatbot channels (VK, Telegram, Messenger Max) for automated customer communication about delivery slots. + +### Key Features + +- **OTP authentication** via Supabase Auth with a demo mode when backend is not configured +- **Role-based dashboard** for 5 roles: Manager, Production Lead, Logistician, Driver, Admin +- **Order lifecycle management** — creation, editing, status transitions, delivery slot scheduling +- **Production queue panel** — Kanban-style view for production workflow +- **Chatbot integration** — unified adapter layer for VK, Telegram, Messenger Max +- **Client-facing delivery page** — token-based link for customers to confirm/reschedule delivery +- **Installable PWA** with offline support (service worker + manifest) +- **Light/dark themes** with responsive, minimalist UI +- **Supabase backend** — PostgreSQL schema with Row-Level Security (RLS), audit triggers, and Supabase Edge Functions + +### Tech Stack + +| Layer | Technology | +|---|---| +| Frontend | React 18, React Router 7, Tailwind CSS, Framer Motion | +| State | React Context (`AuthContext`, `ThemeContext`), local component state | +| Backend | Supabase (PostgreSQL, Auth, Edge Functions) | +| Build | Vite 6 | +| Testing | Vitest | +| Linting | ESLint 9 | +| PWA | Custom service worker + Web App Manifest | + +## Project Structure + +``` +super-sam/ +├── src/ # Frontend source +│ ├── components/ # UI components +│ │ ├── admin/ # Admin panels (users, audit) +│ │ ├── auth/ # Login / OTP components +│ │ ├── chat/ # Chat message components +│ │ ├── client/ # Client-facing delivery page +│ │ ├── dashboard/ # KPI, production queue panels +│ │ ├── driver/ # Driver delivery panel +│ │ ├── logistics/ # Bot control, delivery slots +│ │ ├── orders/ # Order list, card, editor, history +│ │ └── UI/ # Shared UI primitives +│ ├── constants/ # Workflow statuses, transitions +│ ├── context/ # AuthContext, ThemeContext +│ ├── data/ # Mock/demo data +│ ├── hooks/ # useOrders, usePwaStatus, etc. +│ ├── layouts/ # AppShell (sidebar + content) +│ ├── lib/ # Utility libraries +│ ├── pages/ # Route-level pages +│ ├── services/ # Business logic (pure functions) +│ │ └── supabase/ # Supabase repository adapters +│ ├── styles/ # Theme CSS +│ ├── test/ # Test utilities +│ └── utils/ # General utilities +├── supabase/ +│ ├── schema.sql # Full DB schema with RLS policies +│ └── functions/ # Supabase Edge Functions +│ ├── chatbot-webhook/ # Inbound webhook handler +│ ├── send-chatbot-message/ # Outbound message sender +│ ├── confirm-delivery-choice/ +│ ├── create-delivery-invitation/ +│ ├── get-delivery-invitation/ +│ ├── report-delivery-result/ +│ ├── transfer-to-logistics/ +│ └── _shared/ # Shared modules (chatbot, workflow) +├── public/ +│ ├── manifest.webmanifest # PWA manifest +│ ├── service-worker.js # Offline caching +│ └── icons/ # PWA install icons +├── docs/ +│ ├── architecture.md # Frontend architecture +│ ├── chatbot-integration.md # Bot channel integration spec +│ ├── scenarios.md # Order lifecycle scenarios +│ ├── operations/ # Operational docs +│ └── superpowers/ # Feature/agent instructions +└── docker-compose.yml # Self-hosted Supabase stack +``` + +## Building and Running + +### Prerequisites + +- Node.js 18+ +- npm + +### Development + +```bash +npm install # Install dependencies +npm run dev # Start Vite dev server (http://localhost:5173) +``` + +### Production Build + +```bash +npm run build # Build for production (dist/) +npm run preview # Preview production build +``` + +### Linting + +```bash +npm run lint # ESLint check +``` + +### Testing + +```bash +npm run test # Run Vitest tests +``` + +### Local Supabase Stack + +```bash +docker compose up -d # Start self-hosted Supabase +docker compose down # Stop +``` + +### Environment Variables + +Copy `.env.example` to `.env` and configure: + +``` +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key +``` + +Without these variables, the app runs in **demo mode** with local mock data. + +## Architecture Highlights + +### Authentication Flow + +1. User enters email on `/login` +2. Supabase sends OTP via email (or demo mode bypasses this) +3. On success, `AuthContext` loads user profile + role from `public.users` table +4. Role-based routing guards dashboard access + +### Order Service Layer + +- `src/services/orderService.js` — pure functions for order CRUD, status transitions, filtering, metrics, auto-assignment +- `src/services/supabase/orderRepository.js` — Supabase adapter for real reads/writes +- The service layer is decoupled: tests mock data without needing a live database + +### Role-Based Access Control + +| Role | Permissions | +|---|---| +| `manager` | Create/edit own orders, read own orders, manage comments | +| `production_lead` | Read all orders, manage production queue, update production statuses | +| `logistician` | Read assigned orders, manage delivery, manage chatbots | +| `driver` | Read assigned deliveries, update driver statuses (Загружен, В пути, Доставлен) | +| `admin` | Full access to everything | + +RLS policies in `schema.sql` enforce these at the database level. + +### Workflow Statuses + +Orders move through a defined pipeline: +`Новый` → `Подтверждён` → `В очереди производства` → `В производстве` → `Готово к доставке` → `Ожидает ответа клиента` → `Доставка согласована` → `Доставка запланирована` → ... → `Доставка завершена` + +Transitions are gated by role via `src/constants/deliveryWorkflow.js`. + +### Routing + +| Route | Component | Description | +|---|---|---| +| `/` | → redirect to `/dashboard` | | +| `/login` | `LoginPage` | Email OTP authentication | +| `/dashboard` | `DashboardPage` | Role-based control center | +| `/delivery/:token` | `ClientDeliveryPage` | Client-facing delivery confirmation | +| `*` | `NotFoundPage` | 404 fallback | + +## Development Conventions + +- **ESLint** with `eslint:recommended`, `react`, and `react-hooks` plugins +- **Tailwind CSS** with `tailwind-merge` + `clsx` for className composition +- **Framer Motion** for animations and transitions +- **Module alias**: imports use relative paths from `src/` +- **Test coverage**: `orderService.js`, `deliveryInvitationApi.js`, `driverDeliveries.js`, `orderViews.js` all have `.test.js` files +- **Code splitting**: Vite config manually chunks `react`, `supabase`, and `motion` into separate bundles diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ac5b179 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,658 @@ +# Usage +# Start: docker compose up -d +# Stop: docker compose down +# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d +# Reset everything: ./reset.sh +# + +name: supabase + +services: + + studio: + container_name: supabase-studio + image: supabase/studio:2026.03.16-sha-5528817 + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})" + ] + timeout: 10s + interval: 5s + retries: 3 + depends_on: + analytics: + condition: service_healthy + environment: + # Binds nestjs listener to both IPv4 and IPv6 network interfaces + HOSTNAME: "::" + + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + + # LOGFLARE_API_KEY is deprecated + LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} + LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN} + + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: true + # Comment to use Big Query backend for analytics + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + # Uncomment to use Big Query backend for analytics + # NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery + SNIPPETS_MANAGEMENT_FOLDER: /app/snippets + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions + volumes: + - ./volumes/snippets:/app/snippets:Z + - ./volumes/functions:/app/edge-functions:Z + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + networks: + default: + aliases: + - api-gw + coolify: null + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + depends_on: + studio: + condition: service_healthy + ports: + - ${KONG_HTTP_PORT}:8000/tcp + - ${KONG_HTTPS_PORT}:8443/tcp + volumes: + # https://github.com/supabase/supabase/issues/12661 + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z + #- ./volumes/api/server.crt:/home/kong/server.crt:ro + #- ./volumes/api/server.key:/home/kong/server.key:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + KONG_PROXY_ACCESS_LOG: /dev/stdout combined + #KONG_SSL_CERT: /home/kong/server.crt + #KONG_SSL_CERT_KEY: /home/kong/server.key + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + entrypoint: /home/kong/kong-entrypoint.sh + labels: + - "traefik.enable=true" + - "traefik.http.routers.supabase-http.entryPoints=http" + - "traefik.http.routers.supabase-http.rule=Host(`supa.supersamsev.ru`)" + - "traefik.http.routers.supabase-http.middlewares=supabase-redirect" + - "traefik.http.middlewares.supabase-redirect.redirectscheme.scheme=https" + - "traefik.http.routers.supabase-http.service=supabase-kong" + - "traefik.http.routers.supabase-https.entryPoints=https" + - "traefik.http.routers.supabase-https.rule=Host(`supa.supersamsev.ru`)" + - "traefik.http.routers.supabase-https.tls=true" + - "traefik.http.routers.supabase-https.tls.certresolver=letsencrypt" + - "traefik.http.routers.supabase-https.service=supabase-kong" + - "traefik.http.services.supabase-kong.loadbalancer.server.port=8000" + - "traefik.docker.network=coolify" + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.186.0 + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + # Public app URL used in email auth flows. + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + # Keep signup disabled to make OTP login whitelist-only. + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + + # Legacy symmetric HS256 key + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + # JSON array of signing JWKs (EC private + legacy symmetric) + #GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + + # Email OTP is enabled, but account creation must stay disabled above. + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + # Keep false for OTP login so users still verify ownership of the mailbox. + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. + # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true + + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + # SMTP sender settings for auth emails and OTP delivery. + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + + # Uncomment to enable OAuth / social login providers. + # GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} + # GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + # GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} + # GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED} + # GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + # GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET} + # GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED} + # GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + # GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET} + # GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + + # Uncomment to configure SMS delivery (phone auth and phone MFA). + # GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER} + # GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP} + # GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH} + # GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY} + # GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE} + + # Twilio credentials (when SMS_PROVIDER=twilio) + # GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID} + # GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN} + # GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID} + + # Test OTP mappings for development + # GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP} + + # Uncomment to configure multi-factor authentication (MFA). + # GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${MFA_TOTP_ENROLL_ENABLED} + # GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${MFA_TOTP_VERIFY_ENABLED} + # GOTRUE_MFA_PHONE_ENROLL_ENABLED: ${MFA_PHONE_ENROLL_ENABLED} + # GOTRUE_MFA_PHONE_VERIFY_ENABLED: ${MFA_PHONE_VERIFY_ENABLED} + # GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${MFA_MAX_ENROLLED_FACTORS} + + # Uncomment to enable custom access token hook. + # See: https://supabase.com/docs/guides/auth/auth-hooks for + # full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.6 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} + PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} + PGRST_DB_ANON_ROLE: anon + # PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS + PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} + command: + [ + "postgrest" + ] + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.76.5 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 30s + retries: 3 + start_period: 10s + environment: + PORT: 4000 + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + + # Legacy symmetric HS256 key + API_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric) + #API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + METRICS_JWT_SECRET: ${JWT_SECRET} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: "true" + RUN_JANITOR: "true" + DISABLE_HEALTHCHECK_LOGGING: "true" + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.44.2 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://storage:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + start_period: 10s + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + + # Legacy symmetric HS256 key + AUTH_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric) + #JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + REQUEST_ALLOW_X_FORWARDED_PATH: "true" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + # S3 bucket when using S3 backend, directory name when using 'file' + GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET} + # S3 Backend configuration + #GLOBAL_S3_ENDPOINT: https://your-s3-endpoint + #GLOBAL_S3_PROTOCOL: https + #GLOBAL_S3_FORCE_PATH_STYLE: true + #AWS_ACCESS_KEY_ID: your-access-key-id + #AWS_SECRET_ACCESS_KEY: your-secret-access-key + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: ${STORAGE_TENANT_ID} + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: ${REGION} + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + # S3 protocol endpoint configuration + S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID} + S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET} + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.30.1 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: + [ + "CMD", + "imgproxy", + "health" + ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP} + IMGPROXY_MAX_SRC_RESOLUTION: 16.8 + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.95.2 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: ${POSTGRES_HOST} + PG_META_DB_PORT: ${POSTGRES_PORT} + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + CRYPTO_KEY: ${PG_META_CRYPTO_KEY} + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.71.2 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions:Z + - deno-cache:/root/.cache/deno + depends_on: + kong: + condition: service_healthy + environment: + # Legacy symmetric HS256 key + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + # Legacy API keys (HS256-signed JWTs) + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + # New opaque API keys + SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" + SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + # TODO: Allow configuring VERIFY_JWT per function. + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + command: + [ + "start", + "--main-service", + "/home/deno/functions/main" + ] + + analytics: + container_name: supabase-analytics + image: supabase/logflare:1.31.2 + restart: unless-stopped + # ports: + # - 4000:4000 + # Uncomment to use Big Query backend for analytics + # volumes: + # - type: bind + # source: ${PWD}/gcloud.json + # target: /opt/app/rel/logflare/bin/gcloud.json + # read_only: true + healthcheck: + test: + [ + "CMD", + "curl", + "http://localhost:4000/health" + ] + timeout: 5s + interval: 5s + retries: 10 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + environment: + LOGFLARE_NODE_HOST: 127.0.0.1 + DB_USERNAME: supabase_admin + DB_DATABASE: _supabase + DB_HOSTNAME: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_SCHEMA: _analytics + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} + LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN} + LOGFLARE_SINGLE_TENANT: true + LOGFLARE_SUPABASE_MODE: true + + # Comment variables to use Big Query backend for analytics + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + # Uncomment to use Big Query backend for analytics + # GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} + # GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Use named volume to persist pgsodium decryption key between restarts + - db-config:/etc/postgresql-custom + healthcheck: + test: + [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "localhost" + ] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: ${POSTGRES_PORT} + POSTGRES_PORT: ${POSTGRES_PORT} + PGPASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY} + command: + [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs + ] + + vector: + container_name: supabase-vector + image: timberio/vector:0.53.0-alpine + restart: unless-stopped + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z + - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://vector:9001/health" + ] + timeout: 5s + interval: 5s + retries: 3 + environment: + LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} + command: + [ + "--config", + "/etc/vector/vector.yml" + ] + security_opt: + - "label=disable" + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:2.7.4 + restart: unless-stopped + ports: + - ${POSTGRES_PORT}:5432 + - ${POOLER_PROXY_PORT_TRANSACTION}:6543 + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health" + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + CLUSTER_POSTGRES: true + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + VAULT_ENC_KEY: ${VAULT_ENC_KEY} + API_JWT_SECRET: ${JWT_SECRET} + METRICS_JWT_SECRET: ${JWT_SECRET} + REGION: local + ERL_AFLAGS: -proto_dist inet_tcp + POOLER_TENANT_ID: ${POOLER_TENANT_ID} + POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + POOLER_POOL_MODE: transaction + DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE} + command: + [ + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" + ] + +networks: + coolify: + external: true + +volumes: + db-config: + deno-cache: diff --git a/docs/architecture.md b/docs/architecture.md index 9d25124..ca8017d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,34 +2,50 @@ ## Модули -- `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. +- `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. Неизвестный email показывает подсказку обратиться к администратору. - `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`. - `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness. -- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров и действий. +- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets). +- `src/services/deliverySetViews.js` — чистые функции группировки импортированных заказов в наборы доставки с buckets: «На подходе», «Готово к запуску», «Ожидает клиента», «Нужна ручная работа», «Согласовано», «Завершено». - `src/services/orderService.js` — чистые функции бизнес-логики заказов, покрытые тестами. -- `src/services/supabase/orderRepository.js` — адаптер реальных чтений/записей заказов и чатов в Supabase. +- `src/services/supabase/orderRepository.js` — адаптер реальных чтений/записей заказов и чатов в Supabase, включая source-поля 1С и delivery-set поля. +- `src/services/driverDeliveries.js` — фильтрация и группировка доставок для рабочей области водителя. - `src/layouts/AppShell.jsx` — общий shell с боковой навигацией, уведомлениями и переключением темы. +- `src/components/logistics/LogisticsReadinessBoard.jsx` — интерактивная доска с bucket-ами наборов доставки. +- `src/components/logistics/DeliverySetDetailPanel.jsx` — детальная карточка набора доставки: source-поля 1С, production-шаги, слоты, действия. +- `src/components/client/DeliverySlotsPicker.jsx` — публичный виджет выбора даты и половины дня доставки. +- `src/components/client/DeliveryChoiceFlow.jsx` — публичный поток согласования доставки по приглашению. +- `src/components/client/DeliveryStateNotice.jsx` — информационный экран для clients со статусом ссылки. - `src/components/orders/*` — фильтры, список заказов, карточка заказа, история статусов и поиск по чату. - `src/components/orders/OrderEditorPanel.jsx` — создание и редактирование заказа менеджером или администратором. - `src/components/dashboard/ProductionQueuePanel.jsx` — отдельный блок производственной очереди. +- `src/components/dashboard/RoleWorkspacePanel.jsx` — рабочая панель с delivery-set bucket-ами для логиста. - `src/components/admin/UserDirectoryPanel.jsx` — панель пользователей, ролей и последних входов. - `src/components/logistics/BotControlPanel.jsx` — сценарии отправки в чатбот и переноса слотов доставки. - `src/components/admin/AuditPanel.jsx` — журнал ошибок, исключений и обзор последних системных событий. ## Ролевой доступ -- Менеджер видит только свои заказы и может менять статусы на этапах подтверждения. +- 1С остаётся источником создания и прогресса заказов. Веб-приложение не создаёт заказы. - Начальник производства переводит заказ через очередь и производство к готовности. -- Логист работает только со своими заказами, слотами и сообщениями чатбота. -- Водитель видит только назначенные ему доставки и может переводить их через статусы `Загружен`, `В пути`, `Доставлен`. -- Администратор видит весь массив заказов и системные логи. +- **Логист** видит наборы доставки, слоты, сообщения чатбота и ручную обработку исключений. +- **Водитель** видит только назначенные доставки и может переводить их через статусы `Загружен`, `В пути`, `Доставлен`, `Проблема доставки`. +- **Администратор** видит весь массив заказов, доставок и системные логи. +- Клиент не является авторизованным пользователем приложения. Клиент использует публичную ссылку приглашения. ## Ключевые экраны -- `/login` — email + OTP flow. При отсутствии `VITE_SUPABASE_*` включается demo-режим. -- `/dashboard` — role-based control center: KPI, фильтры, список заказов, детализация, боты, аудит. +- `/login` — email + OTP flow. При отсутствии `VITE_SUPABASE_*` включается demo-режим. Подсказка проверять входящие и спам. Неизвестный email: «Email не найден в системе. Обратитесь к администратору.» +- `/dashboard` — role-based control center: для логиста — LogisticsReadinessBoard с наборами доставки, для водителя — план маршрута и быстрые действия. +- `/delivery/:token` — публичная страница согласования доставки для клиента. - `public/manifest.webmanifest` + `public/service-worker.js` — installable PWA-оболочка и базовое кеширование shell для demo offline. -- `src/services/orderService.test.js` — smoke-проверки фильтрации, статусов, метрик и автодистрибуции. + +## Источник заказов: 1С → Supabase + +- Заказы импортируются из 1С через XML и сохраняются в `public.orders` с source-полями: `source_order_number`, `source_customer_name`, `source_accept_at`, `source_ship_at` и т.д. +- Заказы группируются в **наборы доставки** (delivery sets) по `delivery_set_key` из n8n или по нормализованному телефону+имя клиента. +- Набор доставки считается готовым к запуску, когда все его заказы имеют `source_accept_at` и ни один не имеет `source_ship_at`. +- Поле `source_sms_legacy_at` сохраняется как историческое и **не должно** запускать новый сценарий доставки. ## Дизайн-концепт @@ -42,5 +58,5 @@ - `src/supabaseClient.js` создаёт клиент Supabase через env-переменные. - `src/services/safeSupabaseCall.js` стандартизирует обработку ошибок. -- Данные UI уже разложены по сущностям, совпадающим с таблицами Supabase: `orders`, `order_history`, `chat_messages`, `delivery_slots`. -- В `orders` синхронизированы поля `status`, `delivery_agreement_status`, `assigned_driver_id`, чтобы backend и demo-режим использовали одну процессную модель. +- Данные UI разложены по сущностям, совпадающим с таблицами Supabase: `orders`, `order_history`, `chat_messages`, `delivery_slots`, `delivery_invitations`. +- В `orders` синхронизированы поля `status`, `delivery_agreement_status`, `assigned_driver_id`, а также source-поля 1С и delivery-set данные. \ No newline at end of file diff --git a/docs/integrations/delivery-orchestration.md b/docs/integrations/delivery-orchestration.md new file mode 100644 index 0000000..c11d8d9 --- /dev/null +++ b/docs/integrations/delivery-orchestration.md @@ -0,0 +1,245 @@ +# Оркестрация процесса согласования доставки + +## Цель + +Этот документ фиксирует, как должен работать процесс согласования доставки по схеме: + +- готовность заказа определяется внешним XML-файлом на FTP; +- клиент получает SMS со ссылкой на публичную страницу выбора доставки; +- при отсутствии реакции запускаются повторные уведомления и ручная обработка логистом; +- после доставки статус передается обратно во внешнюю учетную систему. + +## Короткий ответ по архитектуре + +Рекомендуемое разделение ответственности такое: + +- `Supabase` — источник истины для пользователей, заказов, статусов, истории, клиентских ссылок и подтвержденных действий. +- `Edge Functions` — безопасные серверные точки входа для изменения состояния заказа и валидации переходов. +- `n8n` — оркестратор расписания и интеграций: FTP XML, SMS-провайдер, напоминания по времени, обмен с `1С` и внешними системами. + +Иными словами: + +- состояние процесса хранится и проверяется в `Supabase`; +- время, расписание и внешние вызовы удобнее и надежнее исполнять в `n8n`. + +## Почему не стоит строить это только на Supabase + +Теоретически часть сценариев можно делать только через `Supabase Edge Functions`, но в этом проекте это будет слабее, чем связка `Supabase + n8n`, по трем причинам: + +1. Источник готовности заказа приходит из внешнего XML на FTP. + Это интеграционный сценарий, а не чисто внутренний backend-flow. + +2. Напоминания завязаны на время. + Нужны регулярные проверки: через 3 часа, через 1 час, через 2 часа, перенос на рабочее время. + +3. Есть внешние каналы и обратный обмен. + SMS, `1С`, возможные ошибки доставки и повторная отправка лучше ложатся на отдельный orchestration-слой. + +Поэтому для этого проекта оптимальная модель такая: + +- `n8n` решает, когда и что запустить; +- `Supabase` решает, можно ли менять статус и что именно записать. + +## Роли систем + +### Supabase + +В `Supabase` хранится: + +- `orders` +- `order_history` +- `delivery_slots` +- `delivery_invitations` +- `integration_events` +- пользователи и роли + +Через `Edge Functions` выполняются только валидные переходы: + +- создание клиентской ссылки; +- чтение состояния ссылки; +- подтверждение выбора клиентом; +- передача логисту; +- перевод в платное хранение; +- фиксация результата доставки. + +### n8n + +В `n8n` реализуются сценарии: + +- чтение XML с FTP по расписанию; +- определение новых заказов, готовых к доставке; +- запуск первой SMS; +- запуск повторной SMS; +- запуск reminder после незавершенного выбора; +- передача заказа логисту при SLA timeout; +- отправка SMS о платном хранении; +- отправка результата доставки во внешнюю систему. + +### Публичное приложение + +Клиентская страница на `dost.supersamsev.ru`: + +- получает token из SMS; +- читает invitation state из `Supabase`; +- показывает допустимый интерфейс; +- сохраняет выбор доставки через `Edge Function`. + +## Основной поток данных + +### 1. Проверка готовности заказа + +`n8n` по расписанию читает XML на FTP. + +На этом шаге workflow: + +- забирает XML; +- парсит записи заказов; +- определяет, какие заказы стали полностью готовыми к доставке; +- сверяет их с `Supabase`. + +Если заказ уже заведен и еще не переведен в delivery agreement flow, `n8n` вызывает `create-delivery-invitation`. + +### 2. Первая SMS + +После успешного вызова `create-delivery-invitation`: + +- в `Supabase` создается или обновляется `delivery_invitations`; +- заказ получает статус `Ожидает ответа клиента`; +- `n8n` отправляет первую SMS со ссылкой. + +### 3. Контроль перехода по ссылке + +`n8n` по расписанию проверяет заказы в статусе `Ожидает ответа клиента`. + +Основание для решения: + +- `delivery_invitations.sent_at` +- `delivery_invitations.opened_at` +- `delivery_invitations.confirmed_at` +- `orders.status` + +Если клиент не перешел по ссылке в течение 3 часов: + +- `n8n` отправляет повторную SMS; +- фиксирует это в `integration_events` или отдельном поле reminder state. + +Если и после повторной SMS перехода нет: + +- `n8n` вызывает `transfer-to-logistics`; +- заказ получает статус `Передан логисту`. + +### 4. Незавершенный выбор + +Если клиент открыл ссылку, но не подтвердил время: + +- это видно по `opened_at`, при этом `confirmed_at` остается пустым; +- `n8n` запускает reminder через 1 час; +- если подтверждения нет еще 2 часа, `n8n` передает заказ логисту. + +Проверка рабочего времени должна жить именно в `n8n`, потому что это orchestration rule. + +### 5. Ручная работа логиста + +После статуса `Передан логисту` автоматический сценарий считается неуспешным и дальше заказ ведет логист. + +Логист: + +- связывается с клиентом; +- согласует доставку вручную; +- либо переводит заказ в `Доставка согласована`; +- либо после безуспешных попыток переводит заказ в `Платное хранение`. + +После перевода в `Платное хранение`: + +- `n8n` отправляет клиенту отдельную SMS. + +### 6. Доставка и обратная интеграция + +После статуса `Доставка согласована`: + +- логист планирует доставку; +- водитель выполняет доставку; +- приложение фиксирует `Доставлен` или `Проблема доставки`. + +При успешной доставке: + +- `n8n` отправляет статус во внешнюю систему. + +При проблеме доставки: + +- заказ возвращается логисту; +- цикл ручного согласования повторяется. + +## Где живет “понятие времени” + +Это ключевой вопрос проекта. + +Само приложение не должно самостоятельно “просыпаться” и решать, что прошло 3 часа. + +Правильная модель такая: + +- приложение и `Supabase` только фиксируют события и timestamps; +- `n8n` регулярно запускается по cron и вычисляет, наступило ли время следующего действия. + +То есть логика выглядит так: + +- `Supabase` хранит `sent_at`, `opened_at`, `confirmed_at`, текущий статус; +- `n8n` каждые 5-15 минут проверяет, какие записи пересекли SLA-порог; +- если порог наступил, `n8n` запускает нужный сценарий. + +## Рекомендуемый набор workflow в n8n + +1. `FTP XML Poller` + Читает XML с FTP и определяет готовые заказы. + +2. `Delivery Offer Dispatcher` + Создает invitation и отправляет первую SMS. + +3. `Delivery Reminder Scheduler` + Проверяет timeouts: + - 3 часа до повторной SMS + - еще 3 часа до передачи логисту + - 1 час до reminder после opened/no-confirm + - 2 часа до передачи логисту после reminder + +4. `Paid Storage Notifier` + Отправляет SMS при статусе `Платное хранение`. + +5. `Delivery Result Exporter` + Передает итог доставки во внешнюю систему. + +## Минимальный набор данных, который нужен для orchestration + +Чтобы схема работала стабильно, в `Supabase` должны быть доступны как минимум: + +- `orders.id` +- `orders.order_number` +- `orders.status` +- `orders.delivery_agreement_status` +- `delivery_invitations.order_id` +- `delivery_invitations.token_hash` +- `delivery_invitations.state` +- `delivery_invitations.sent_at` +- `delivery_invitations.opened_at` +- `delivery_invitations.confirmed_at` +- `delivery_invitations.logistics_transferred_at` +- `delivery_invitations.paid_storage_at` +- `delivery_invitations.delivered_at` +- `delivery_slots` +- `integration_events` + +## Итоговое решение + +Для этого проекта рабочая и рекомендуемая архитектура такая: + +- FTP XML читает `n8n`; +- time-based automation исполняет `n8n`; +- SMS и внешние интеграции исполняет `n8n`; +- `Supabase` хранит бизнес-состояние; +- `Edge Functions` валидируют и фиксируют изменения состояния; +- клиентское приложение показывает состояние и принимает подтверждение клиента. + +Это дает понятное разделение: + +- `n8n` отвечает за “когда запускать сценарий”; +- `Supabase` отвечает за “какое состояние заказа сейчас истинно”. diff --git a/docs/scenarios.md b/docs/scenarios.md index ca56e45..b0e8b73 100644 --- a/docs/scenarios.md +++ b/docs/scenarios.md @@ -1,45 +1,70 @@ # Сценарии работы приложения -## 1. Менеджер создаёт заказ +## 1. Оператор входит в систему -1. Менеджер входит по email и OTP. -2. Создаёт заказ с клиентом, адресом, каналом связи и комментарием. -3. Заказ получает статус `Новый`. -4. После проверки менеджер переводит его в `Подтверждён`. -5. Затем переводит в `В очереди производства`. +1. Оператор (логист, водитель или админ) открывает `/login` и вводит email. +2. Система отправляет одноразовый код на почту. На экране подсказка: «Проверьте входящие и папку Спам». +3. Если email не найден в системе, показывается: «Email не найден в системе. Обратитесь к администратору.» +4. После ввода верного кода оператор попадает в дашборд согласно роли. -## 2. Производство запускает заказ +## 2. Логист открывает рабочее пространство доставки -1. Начальник производства открывает очередь. -2. Переводит заказ в `В производстве`. -3. По завершении меняет статус на `Готово к доставке`. -4. В `order_history` фиксируются пользователь, время и переход статусов. +1. Логист видит **LogisticsReadinessBoard** — доску с наборами доставки, сгруппированными по статусам: + - **На подходе**: не все заказы набора приняты ОТК. + - **Готово к запуску**: все заказы приняты, можно запускать доставку. + - **Ожидает клиента**: отправлено приглашение, ждём ответа. + - **Нужна ручная работа**: передано логисту, платное хранение, проблема. + - **Согласовано**: клиент подтвердил слот. + - **Завершено**: все заказы доставлены. +2. Клик по набору открывает **DeliverySetDetailPanel** с: + - Перечнем заказов набора, их 1С-номерами и шагами производства (раскрой, склейка, криволинейные, приёмка ОТК, отгрузка). + - Телефоном и email клиента, городом, связанными счетами. + - Текущим статусом слота. +3. Логист может запустить приглашение, назначить водителя или перейти к ручной обработке. -## 3. Логист согласует доставку через чатбот +## 3. Согласование доставки с клиентом -1. Логист видит заказ со статусом `Готово к доставке`. -2. Система или логист отправляет сообщение в VK, Telegram или Messenger Max. -3. Статус меняется на `Согласование с клиентом через чатбот`. -4. Клиент подтверждает слот. -5. Статус переходит в `Доставка запланирована`. +1. Когда набор доставки готов, логист запускает отправку приглашения клиенту. +2. Клиент получает ссылку на `/delivery/:token`. +3. На странице клиент видит **DeliverySlotsPicker** с доступными датами и половинами дня. +4. Клиент выбирает слот и подтверждает. Статус набора переходит в «Ожидает клиента» → «Согласовано». +5. Если клиент не отвечает, система или логист переводит набор в «Нужна ручная работа». -## 4. Клиент переносит доставку +## 4. Перенос доставки -1. Клиент жмёт кнопку переноса. -2. Бот предлагает новые слоты. +1. Клиент нажимает «Запросить новую ссылку», логист получает уведомление. +2. Логист может переназначить слот или отправить новое приглашение. 3. Выбранный слот сохраняется в `delivery_slots`. -4. В заказе фиксируется новый статус `Доставка перенесена`. -5. Логист и менеджер получают уведомление. ## 5. Исключение 1. Если клиент не отвечает в течение SLA, система создаёт исключение. -2. Статус становится `Исключение: отсутствие ответа клиента`. -3. Администратор видит инцидент в панели аудита. -4. После ручной обработки логист может снова отправить сообщение или отменить доставку. +2. Набор переходит в «Нужна ручная работа». +3. Логист может снова отправить приглашение, перевести в платное хранение или отменить. +4. Администратор видит инцидент в панели аудита. -## 6. Завершение доставки +## 6. Водитель выполняет доставку -1. Логист отмечает фактическую доставку. -2. Заказ получает статус `Доставка завершена`. +1. Водитель видит назначенные доставки с адресом, городом, интервалом и составом заказа. +2. Переводит статус: Загружен → В пути → Доставлен. +3. При проблеме переводит в «Проблема доставки». + +## 7. Завершение доставки + +1. Статус «Доставлен» подтверждает физическую передачу заказа клиенту. +2. После закрытия всех заказов набора он переходит в «Завершено». 3. В истории появляется финальная запись, а чат закрывается для активных действий. + +## Demo-скрипт для первого платного milestone + +1. Зайти под логистом (email: `mk7029953@yandex.ru`). +2. На дашборде увидеть LogisticsReadinessBoard с наборами: + - Волкова М.А. — «На подходе» (кухня готова, столешница ещё в производстве). + - Савин А.П. — «Готово к запуску» (все заказы приняты ОТК). + - Тарасова Е.И. — «Ожидает клиента» (приглашение отправлено). + - Фролова И.Д. — «Нужна ручная работа» (платное хранение). + - Орлова Н.С. — «Завершено». +3. Кликнуть по набору Савина — увидеть source-поля, production-шаги, готовность к запуску. +4. Перейти на публичную страницу приглашения — увидеть DeliverySlotsPicker с выбором даты и половины дня. +5. Зайти под водителем — увидеть назначенные доставки с адресами и быстрыми действиями. +6. Зайти под несуществующим email — увидеть «Email не найден в системе. Обратитесь к администратору.» \ No newline at end of file diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md new file mode 100644 index 0000000..e53f269 --- /dev/null +++ b/docs/superpowers/README.md @@ -0,0 +1,103 @@ +# Superpowers — Development Workflow System + +A set of composable skills for disciplined, high-quality software development. Adapted from [obra/superpowers](https://github.com/obra/superpowers) for the Construction Delivery Control project. + +## Philosophy + +- **Design before code** — Never jump straight into implementation +- **Tests before code** — TDD is non-negotiable +- **Root cause before fixes** — Systematic debugging over guesswork +- **Evidence before claims** — Verify everything before stating success +- **Review before merge** — Catch issues early, catch them often +- **Isolation for focus** — Use git worktrees for clean development + +## Skills Overview + +| Skill | When to Use | What It Does | +|-------|-------------|--------------| +| **[brainstorming](skills/brainstorming/SKILL.md)** | Before any creative work | Explores intent, requirements, design. Produces a spec doc. | +| **[writing-plans](skills/writing-plans/SKILL.md)** | After spec is approved | Breaks work into 2-5 minute tasks with exact code and paths. | +| **[subagent-driven-development](skills/subagent-driven-development/SKILL.md)** | Executing a plan | Dispatches fresh subagent per task with two-stage review. | +| **[executing-plans](skills/executing-plans/SKILL.md)** | Executing a plan inline | Executes tasks in-session with checkpoints. | +| **[test-driven-development](skills/test-driven-development/SKILL.md)** | Implementing any task | RED-GREEN-REFACTOR cycle. Tests first, always. | +| **[systematic-debugging](skills/systematic-debugging/SKILL.md)** | Any bug or test failure | 4-phase root cause investigation. No fixes without understanding. | +| **[requesting-code-review](skills/requesting-code-review/SKILL.md)** | After tasks, before merge | Dispatches reviewer subagent with focused context. | +| **[verification-before-completion](skills/verification-before-completion/SKILL.md)** | Before any success claim | Runs fresh verification commands before claiming results. | +| **[using-git-worktrees](skills/using-git-worktrees/SKILL.md)** | Starting feature work | Creates isolated workspace on new branch. | +| **[finishing-a-development-branch](skills/finishing-a-development-branch/SKILL.md)** | All tasks complete | Verifies tests, presents integration options, cleans up. | + +## Workflow: Idea → Production + +``` +┌─────────────────────────────────────────────────────────────┐ +│ IDEAL WORKFLOW │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. brainstorming │ +│ ↓ (spec doc in docs/superpowers/specs/) │ +│ 2. writing-plans │ +│ ↓ (plan doc in docs/superpowers/plans/) │ +│ 3. using-git-worktrees │ +│ ↓ (isolated .worktrees/branch-name) │ +│ 4. subagent-driven-development (or executing-plans) │ +│ ↓ (per task: implement → spec-review → quality-review) │ +│ 5. finishing-a-development-branch │ +│ ↓ (merge / PR / keep / discard) │ +│ │ +│ Throughout: │ +│ - test-driven-development (every task) │ +│ - systematic-debugging (any bug) │ +│ - verification-before-completion (every claim) │ +│ - requesting-code-review (between tasks) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +### For a New Feature + +1. **Tell me what you want to build** → I'll use `brainstorming` +2. We iterate on the design together → I'll write a spec +3. I'll create an implementation plan with `writing-plans` +4. I'll set up a worktree with `using-git-worktrees` +5. I'll execute the plan with `subagent-driven-development` or `executing-plans` +6. When done, `finishing-a-development-branch` gives you merge/PR options + +### For a Bug Fix + +1. **Tell me what's broken** → I'll use `systematic-debugging` +2. I'll find root cause before proposing any fix +3. I'll write a failing test with `test-driven-development` +4. I'll fix, verify, and commit + +### For Code Review + +1. **Ask for a review** → I'll use `requesting-code-review` +2. A reviewer subagent gets focused context (not session history) +3. Issues are reported by severity and fixed before proceeding + +## Project-Specific Conventions + +- **Test runner:** Vitest (`npm run test`) +- **Linter:** ESLint 9 (`npm run lint`) +- **Build:** Vite 6 (`npm run build`) +- **Test files:** `.test.js` co-located with source +- **Components:** Organized by domain in `src/components/` +- **Services:** Pure functions in `src/services/`, Supabase adapters in `src/services/supabase/` +- **Worktrees:** `.worktrees/` directory (already exists and gitignored) +- **Specs:** `docs/superpowers/specs/` +- **Plans:** `docs/superpowers/plans/` + +## Skill Triggering + +Skills are **mandatory workflows**, not optional suggestions. When the context matches a skill's description, I will announce its use and follow the prescribed process. You can always ask me to use a specific skill by name. + +## Key Principles + +1. **Design before code** — Unexamined assumptions cause wasted work +2. **Tests before code** — If you didn't watch it fail, you don't know what it tests +3. **Root cause before fixes** — Random fixes create new bugs +4. **Evidence before claims** — Run the command, read the output, THEN claim the result +5. **Review before merge** — Fresh eyes catch what tired eyes miss +6. **Isolation for focus** — Clean workspace, clean mind diff --git a/docs/superpowers/plans/2026-03-15-mobile-wave-1.md b/docs/superpowers/plans/2026-03-15-mobile-wave-1.md new file mode 100644 index 0000000..3b48658 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-mobile-wave-1.md @@ -0,0 +1,126 @@ +# Mobile Wave 1 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сделать первую волну полноценной мобильной адаптации для `shell + login + orders workspace`, чтобы с телефона можно было полноценно работать, а не только просматривать данные. + +**Architecture:** Бизнес-логика и роуты сохраняются, а responsive-представление добавляется внутри существующих экранов. Для мобильных устройств делаем отдельные представления там, где desktop UX принципиально не работает: навигация shell, реестр заказов, канбан, календарь, карточка заказа, редактор заказа и компактные фильтры. + +**Tech Stack:** React 18, existing UI kit, Vite, Vitest, responsive Tailwind utility classes, current orders hooks/services. + +--- + +## Chunk 1: Mobile Shell And Login + +### Task 1: Зафиксировать mobile shell/login в тестах + +**Files:** +- Create: `src/layouts/AppShell.test.jsx` +- Modify or Create: auth/login test file if needed + +- [ ] Написать падающие тесты на: + - мобильную шапку и нижнюю навигацию в shell + - отсутствие desktop sidebar controls в mobile branch + - компактную мобильную компоновку login +- [ ] Прогнать только новые тесты. + - Run: `npm test -- src/layouts/AppShell.test.jsx` + - Expected: FAIL + +### Task 2: Реализовать mobile shell и login layout + +**Files:** +- Modify: `src/layouts/AppShell.jsx` +- Modify: `src/pages/LoginPage.jsx` +- Modify: `src/components/auth/OtpLoginForm.jsx` if needed + +- [ ] Добавить mobile header и bottom nav в `AppShell`. +- [ ] Сохранить desktop sidebar на больших экранах. +- [ ] Ужать mobile padding и ширину login flow. +- [ ] Прогнать таргетированные тесты shell/login. + +## Chunk 2: Orders List, Filters, And Calendar + +### Task 3: Зафиксировать mobile registry/filters/calendar в тестах + +**Files:** +- Create: `src/components/orders/OrdersTable.test.jsx` +- Modify: `src/components/orders/OrderFilters.test.jsx` +- Create or Modify: `src/components/orders/OrdersCalendarView.test.jsx` + +- [ ] Добавить падающие тесты на: + - mobile card list вместо таблицы в orders registry + - compact filter entrypoint and active chips + - mobile agenda/list view for calendar +- [ ] Прогнать только этот набор тестов. + - Run: `npm test -- src/components/orders/OrdersTable.test.jsx src/components/orders/OrderFilters.test.jsx src/components/orders/OrdersCalendarView.test.jsx` + - Expected: FAIL + +### Task 4: Реализовать mobile registry/filters/calendar + +**Files:** +- Modify: `src/components/orders/OrdersTable.jsx` +- Modify: `src/components/orders/OrderFilters.jsx` +- Modify: `src/components/orders/OrdersCalendarView.jsx` +- Modify: `src/components/UI/Modal.jsx` if it helps mobile filter overlay + +- [ ] Добавить mobile card list for registry. +- [ ] Сделать compact mobile filters pattern. +- [ ] Показать active filter chips на мобильном. +- [ ] Добавить mobile agenda view to calendar. +- [ ] Прогнать таргетированные тесты. + +## Chunk 3: Kanban, Detail, And Editor + +### Task 5: Зафиксировать mobile detail/editor/kanban behavior в тестах + +**Files:** +- Modify: `src/components/orders/OrdersKanbanBoard.test.jsx` +- Create: `src/components/orders/OrderDetailPanel.test.jsx` +- Create: `src/components/orders/OrderEditorPanel.test.jsx` + +- [ ] Добавить падающие тесты на: + - stacked kanban toolbar and horizontal mobile board + - mobile single-column detail layout + - editor sticky action bar / single-column form +- [ ] Прогнать только этот набор тестов. + - Run: `npm test -- src/components/orders/OrdersKanbanBoard.test.jsx src/components/orders/OrderDetailPanel.test.jsx src/components/orders/OrderEditorPanel.test.jsx` + - Expected: FAIL + +### Task 6: Реализовать mobile kanban/detail/editor + +**Files:** +- Modify: `src/components/orders/OrdersKanbanBoard.jsx` +- Modify: `src/components/orders/OrderDetailPanel.jsx` +- Modify: `src/components/orders/OrderEditorPanel.jsx` +- Modify: `src/pages/DashboardPage.jsx` + +- [ ] Завершить mobile kanban toolbar and board ergonomics. +- [ ] Сделать detail view mobile-friendly and single-column. +- [ ] Сделать editor mobile-friendly with sticky CTA. +- [ ] Обновить DashboardPage where mobile overlays/workspace behavior depends on breakpoint. +- [ ] Прогнать таргетированные тесты. + +## Chunk 4: Verification + +### Task 7: Полная проверка волны 1 + +**Files:** +- Reference: `docs/superpowers/specs/2026-03-15-mobile-wave-1-design.md` + +- [ ] Прогнать полный тестовый набор. + - Run: `npm test` + - Expected: PASS +- [ ] Прогнать линтер. + - Run: `npm run lint` + - Expected: PASS +- [ ] Прогнать production build. + - Run: `npm run build` + - Expected: PASS +- [ ] Ручная проверка на narrow viewport: + - login + - mobile shell + - registry + - kanban + - calendar + - order detail + - order editor diff --git a/docs/superpowers/plans/2026-03-15-role-aware-orders-kanban.md b/docs/superpowers/plans/2026-03-15-role-aware-orders-kanban.md new file mode 100644 index 0000000..81ab3e3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-role-aware-orders-kanban.md @@ -0,0 +1,190 @@ +# Role-Aware Orders Kanban Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сделать единый канбан заказов с переключением `по этапам / по статусам`, ролевой видимостью, цветовой индикацией ответственности, подсветкой зависших заказов и едиными правилами изменения статусов. + +**Architecture:** Логика канбана и зависаний строится из метаданных статусов в `deliveryWorkflow`, а сервис `orderViews` собирает колонки и вычисляемые поля для обоих режимов отображения. `useOrders` меняет правила видимости на responsibility-based, а UI в `DashboardPage`, `OrderFilters` и `OrderDetailPanel` переиспользует общие transition-правила и новые aging-флаги. + +**Tech Stack:** React 18, Vite, existing UI components, Vitest, existing order services/hooks. + +--- + +## Chunk 1: Workflow Metadata And View Builders + +### Task 1: Зафиксировать stage/SLA-логику в тестах order views + +**Files:** +- Modify: `src/services/orderViews.test.js` +- Reference: `src/constants/deliveryWorkflow.js` +- Reference: `src/data/mockAppData.js` + +- [ ] Добавить падающие тесты на: + - построение колонок в режиме `by_stage` + - построение колонок в режиме `by_status` + - вычисление aging state `normal / warning / critical` + - скрытие завершённых заказов по умолчанию +- [ ] Прогнать только тесты order views. + - Run: `npm test -- src/services/orderViews.test.js` + - Expected: FAIL по отсутствующим полям/логике + +### Task 2: Расширить метаданные статусов + +**Files:** +- Modify: `src/constants/deliveryWorkflow.js` + +- [ ] Добавить для каждого статуса: + - `stageKey` + - `stageLabel` + - `warningAfterHours` + - `criticalAfterHours` +- [ ] Экспортировать вспомогательные константы и функции: + - список этапов + - получение stage для статуса + - получение ownerRole/status SLA +- [ ] Сохранить обратную совместимость существующих комментариев, tone и transitions. +- [ ] Не менять набор доступных переходов без прямой необходимости. + +### Task 3: Реализовать новый builder для канбана и aging + +**Files:** +- Modify: `src/services/orderViews.js` +- Test: `src/services/orderViews.test.js` + +- [ ] Добавить helper для вычисления времени в текущем статусе через history. +- [ ] Добавить helper для вычисления aging badge и state. +- [ ] Реализовать общий builder колонок с режимами: + - `by_stage` + - `by_status` +- [ ] Добавить в элементы колонок вычисляемые поля: + - `ownerRole` + - `stageKey` + - `stageLabel` + - `agingState` + - `statusAgeHours` + - `statusAgeLabel` +- [ ] Прогнать тесты order views. + - Run: `npm test -- src/services/orderViews.test.js` + - Expected: PASS + +## Chunk 2: Responsibility-Based Visibility And Filters + +### Task 4: Зафиксировать новую видимость и поиск в тестах order service + +**Files:** +- Modify: `src/services/orderService.test.js` +- Modify: `src/services/orderService.js` + +- [ ] Добавить падающие тесты на: + - видимость всех заказов текущей роли по `ownerRole`, а не по assignment + - поиск по телефону клиента + - фильтр по этапу + - фильтр по зоне ответственности + - фильтр по aging state +- [ ] Прогнать только тесты order service. + - Run: `npm test -- src/services/orderService.test.js` + - Expected: FAIL + +### Task 5: Реализовать responsibility-based фильтрацию + +**Files:** +- Modify: `src/services/orderService.js` +- Modify: `src/hooks/useOrders.js` +- Test: `src/services/orderService.test.js` + +- [ ] Расширить `buildSearchBlob` полем телефона клиента и stage/responsibility значениями при необходимости. +- [ ] Изменить фильтрацию видимости: + - manager/admin видят всё + - остальные роли видят заказы по текущему `ownerRole` +- [ ] Добавить в filters новые поля: + - `stage` + - `ownerRole` + - `agingState` +- [ ] Передавать новые filters через `useOrders`. +- [ ] Прогнать тесты order service. + - Run: `npm test -- src/services/orderService.test.js` + - Expected: PASS + +## Chunk 3: Kanban And Detail UI + +### Task 6: Обновить фильтры заказов + +**Files:** +- Modify: `src/components/orders/OrderFilters.jsx` +- Reference: `src/constants/deliveryWorkflow.js` +- Reference: `src/constants/roles.js` + +- [ ] Добавить select-поля для: + - этапа + - зоны ответственности + - зависаний +- [ ] Обновить placeholder поиска под номер заявки, имя/фамилию и телефон клиента. +- [ ] Сохранить текущие фильтры менеджера, логиста и канала. + +### Task 7: Переделать канбан-вкладку вокруг общего builder + +**Files:** +- Modify: `src/pages/DashboardPage.jsx` +- Optionally Create: `src/components/orders/OrdersKanbanBoard.jsx` +- Optionally Create: `src/components/orders/OrdersKanbanCard.jsx` + +- [ ] Добавить переключатель режима: + - `По этапам` + - `По статусам` +- [ ] Подключить новый builder колонок с параметрами режима и completed toggle. +- [ ] Показать счётчики warning/critical по колонкам. +- [ ] Обновить карточки: + - роль-ответственный цвет + - точный статус + - aging badge + - явная подсветка зависших заказов +- [ ] Оставить открытие карточки заказа по клику. + +### Task 8: Свести drag-and-drop к единым transition-правилам + +**Files:** +- Modify: `src/pages/DashboardPage.jsx` +- Modify: `src/components/orders/OrderDetailPanel.jsx` +- Reference: `src/constants/deliveryWorkflow.js` + +- [ ] Добавить helper выбора drop-status внутри stage/status колонок. +- [ ] Разрешать перенос только если для роли пользователя есть допустимый target status. +- [ ] Если перенос невозможен, не менять заказ и не маскировать ошибку. +- [ ] Убедиться, что detail panel и kanban используют один и тот же список доступных переходов. + +## Chunk 4: Notification Signals And Verification + +### Task 9: Добавить in-app сигналы по зависшим заказам + +**Files:** +- Modify: `src/hooks/useOrders.js` +- Modify: `src/pages/DashboardPage.jsx` + +- [ ] Вычислить warning/critical summary по видимым заказам. +- [ ] Показать краткий блок или баннер с количеством зависающих и просроченных заказов. +- [ ] Добавить быстрый переход/фильтр на зависшие заказы из канбана. + +### Task 10: Проверить регрессии и завершить + +**Files:** +- Modify: `README.md` if needed +- Reference: `docs/superpowers/specs/2026-03-15-role-aware-orders-kanban-design.md` + +- [ ] Прогнать таргетированные тесты. + - Run: `npm test -- src/services/orderViews.test.js src/services/orderService.test.js` + - Expected: PASS +- [ ] Прогнать полный тестовый набор. + - Run: `npm test` + - Expected: PASS +- [ ] Прогнать линтер. + - Run: `npm run lint` + - Expected: PASS +- [ ] Запустить production build. + - Run: `npm run build` + - Expected: PASS +- [ ] Коротко вручную проверить: + - менеджер видит весь поток + - логист/водитель/производство видят только свою текущую зону + - канбан переключается между этапами и статусами + - зависшие карточки подсвечиваются + - статус меняется и drag-and-drop, и из detail panel diff --git a/docs/superpowers/plans/2026-03-30-delivery-platform-contract-plan.md b/docs/superpowers/plans/2026-03-30-delivery-platform-contract-plan.md new file mode 100644 index 0000000..a734dc7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-delivery-platform-contract-plan.md @@ -0,0 +1,269 @@ +# Delivery Platform Contract Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Реализовать договорной объем проекта: self-hosted кабинет с ролями, клиентскую страницу согласования доставки, автоматизации на `n8n`/`Edge Functions`, ручную обработку логистом, платное хранение, интеграцию с `1С`, обучение и приемку по 3 этапам. + +**Architecture:** Основа остается в существующем React + Supabase приложении. Внутренние рабочие места продолжают жить в текущем dashboard, а клиентский сценарий выносится в отдельный публичный route с серверной валидацией состояния заказа. Критичные бизнес-переходы и интеграционные контракты фиксируются в Supabase schema/Edge Functions, а `n8n` используется как orchestration-слой для SMS, напоминаний и обмена с `1С`. + +**Tech Stack:** React 18, Vite, existing UI kit, Vitest, Supabase self-hosted, Supabase SQL/RLS, Supabase Edge Functions, `n8n`, self-hosted VPS deployment docs. + +--- + +## File Structure + +- Modify: `src/router.jsx` — добавить публичный клиентский route и сохранить текущую dashboard-навигацию. +- Create: `src/pages/ClientDeliveryPage.jsx` — страница выбора доставки по ссылке. +- Create: `src/components/client/DeliveryChoiceFlow.jsx` — основной клиентский flow выбора/подтверждения/информационных состояний. +- Create: `src/components/client/DeliveryStateNotice.jsx` — read-only состояния: уже согласовано, передано логисту, платное хранение, доставлено. +- Create: `src/components/client/DeliveryChoiceFlow.test.jsx` — UI/flow тесты клиентской страницы. +- Modify: `src/constants/deliveryWorkflow.js` — новые статусы и метаданные (`Ожидает ответа клиента`, `Передан логисту`, `Платное хранение`). +- Modify: `src/services/orderService.js` — чистые переходы статусов, напоминания, ручная передача логисту, исключения доставки. +- Modify: `src/services/orderService.test.js` — тесты на новые переходы и guard rules. +- Modify: `src/data/mockAppData.js` — договорные demo-сценарии ролей, логиста, клиента и неуспешной доставки. +- Modify: `src/pages/DashboardPage.jsx` — вывести логисту ручную обработку, платное хранение и delivery exceptions. +- Modify: `src/components/logistics/BotControlPanel.jsx` or split — логистические действия для ручного согласования и повторной доставки. +- Modify: `src/components/driver/DriverDeliveryPlanner.jsx` +- Modify: `src/components/driver/DriverDeliveryDetail.jsx` — фиксация успешной/неуспешной доставки и причины. +- Modify: `supabase/schema.sql` — invitation/session tables, integration event tables, дополнительные статусы/поля. +- Modify: `supabase/functions/_shared/workflow.ts` — серверная карта переходов для delivery agreement flow. +- Create: `supabase/functions/_shared/delivery-invitations.ts` — общая логика поиска/валидации активной клиентской ссылки. +- Create: `supabase/functions/create-delivery-invitation/index.ts` — создание активной клиентской ссылки для `n8n`. +- Create: `supabase/functions/get-delivery-invitation/index.ts` — отдача публичного состояния клиентской страницы. +- Create: `supabase/functions/confirm-delivery-choice/index.ts` — подтверждение half-day выбора клиента. +- Create: `supabase/functions/transfer-to-logistics/index.ts` — ручная передача заказа логисту/платное хранение по automation signals. +- Create: `supabase/functions/report-delivery-result/index.ts` — фиксация delivery result + payload для `1С`. +- Modify: `supabase/functions/README.md` — обновить serverless contracts. +- Create: `docs/operations/self-hosted-deploy.md` — требования к VPS, доменам, балансам, старту работ. +- Create: `docs/integrations/n8n-delivery-flow.md` — webhook contracts, reminder schedule, SMS/`1С` responsibilities. +- Create: `docs/training/logistics-playbook.md` +- Create: `docs/training/driver-playbook.md` +- Modify: `docs/scenarios.md` +- Modify: `docs/architecture.md` + +## Chunk 1: Contract Stage 1 Foundation And Demo Approval + +### Task 1: Зафиксировать self-hosted стартовые условия и договорные ограничения + +**Files:** +- Create: `docs/operations/self-hosted-deploy.md` +- Modify: `README.md` + +- [ ] Описать старт работ: VPS/Beget or agreed provider, домен/поддомен для app и Supabase, баланс минимум на 2 месяца. +- [ ] Зафиксировать, что `1С`, SMS и домены предоставляет/обеспечивает Заказчик. +- [ ] Прогнать sanity-check документации вручную. + +### Task 2: Подготовить demo-модель статусов и ролей под договор + +**Files:** +- Modify: `src/constants/deliveryWorkflow.js` +- Modify: `src/data/mockAppData.js` +- Modify: `src/services/orderService.js` +- Modify: `src/services/orderService.test.js` + +- [ ] Написать падающие тесты на новые статусы: + - `Ожидает ответа клиента` + - `Передан логисту` + - `Платное хранение` + - повторная доставка после `Проблема доставки` +- [ ] Прогнать таргетированные тесты. + - Run: `npm test -- src/services/orderService.test.js` + - Expected: FAIL +- [ ] Реализовать минимальные переходы в `orderService`. +- [ ] Обновить workflow metadata и demo data под новые сценарии. +- [ ] Прогнать таргетированные тесты повторно. + - Run: `npm test -- src/services/orderService.test.js` + - Expected: PASS + +### Task 3: Добавить публичный demo-route клиентского выбора доставки + +**Files:** +- Modify: `src/router.jsx` +- Create: `src/pages/ClientDeliveryPage.jsx` +- Create: `src/components/client/DeliveryChoiceFlow.jsx` +- Create: `src/components/client/DeliveryStateNotice.jsx` +- Create: `src/components/client/DeliveryChoiceFlow.test.jsx` + +- [ ] Написать падающие UI-тесты на: + - активный выбор half-day + - уже согласованную доставку + - передан логисту + - платное хранение +- [ ] Прогнать только новый клиентский набор тестов. + - Run: `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx` + - Expected: FAIL +- [ ] Реализовать публичную страницу и базовые read/write состояния на demo data. +- [ ] Прогнать тесты повторно. + - Run: `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx` + - Expected: PASS + +### Task 4: Показать логисту ручную ветку в dashboard + +**Files:** +- Modify: `src/pages/DashboardPage.jsx` +- Modify: `src/components/logistics/BotControlPanel.jsx` +- Modify: related test files if coverage already exists + +- [ ] Вывести для логиста заказы в `Ожидает ответа клиента`, `Передан логисту`, `Платное хранение`, `Проблема доставки`. +- [ ] Добавить действия ручного согласования и передачи на следующий статус в demo mode. +- [ ] Ручная проверка stage 1 demo: + - login by role + - internal dashboard + - client page + - manual logistics branch + +## Chunk 2: Contract Stage 2 Core Delivery Workflow + +### Task 5: Спроектировать и внедрить schema changes для delivery invitation flow + +**Files:** +- Modify: `supabase/schema.sql` +- Modify: `supabase/functions/_shared/workflow.ts` + +- [ ] Написать SQL-план таблиц/полей для: + - client invitation/session + - event/audit integration state + - optional manual handling reason fields +- [ ] Обновить server-side workflow map под новые статусы. +- [ ] Проверить schema diff вручную на совместимость с текущими `orders`, `delivery_slots`, `order_history`. + +### Task 6: Реализовать Edge Functions для клиентской ссылки и логистической передачи + +**Files:** +- Create: `supabase/functions/_shared/delivery-invitations.ts` +- Create: `supabase/functions/create-delivery-invitation/index.ts` +- Create: `supabase/functions/get-delivery-invitation/index.ts` +- Create: `supabase/functions/confirm-delivery-choice/index.ts` +- Create: `supabase/functions/transfer-to-logistics/index.ts` +- Modify: `supabase/functions/README.md` + +- [ ] Написать падающие tests for shared workflow/helpers where feasible. +- [ ] Реализовать создание активной ссылки без TTL-expiry logic, только по status guard. +- [ ] Реализовать confirm half-day choice и перевод в `Доставка согласована`. +- [ ] Реализовать transfer to logistics для reminder escalation. +- [ ] Обновить function README с request/response contract. + +### Task 7: Перевести клиентский route с demo на реальный Supabase contract + +**Files:** +- Modify: `src/pages/ClientDeliveryPage.jsx` +- Modify: `src/components/client/DeliveryChoiceFlow.jsx` +- Modify: `src/components/client/DeliveryStateNotice.jsx` +- Modify or Create: supporting client-side service file if needed + +- [ ] Подключить загрузку invitation state из Edge Function. +- [ ] Подключить подтверждение выбора half-day. +- [ ] Показать серверные состояния: + - active choice + - already agreed + - transferred to logistics + - paid storage + - delivered +- [ ] Прогнать client flow tests и обновить их под реальный contract. + +### Task 8: Описать `n8n` orchestration и внешние зависимости + +**Files:** +- Create: `docs/integrations/n8n-delivery-flow.md` +- Modify: `docs/scenarios.md` + +- [ ] Зафиксировать в docs: + - SMS #1 + - SMS #2 after no click + - reminder after opened-but-not-confirmed + - transfer to logistics + - paid storage notification + - `1С` inbound/outbound responsibilities of customer +- [ ] Добавить webhook payload examples for `n8n`. + +## Chunk 3: Contract Stage 3 End-To-End Integrations And Operations + +### Task 9: Реализовать delivery result flow и integration payloads for `1С` + +**Files:** +- Create: `supabase/functions/report-delivery-result/index.ts` +- Modify: `src/components/driver/DriverDeliveryPlanner.jsx` +- Modify: `src/components/driver/DriverDeliveryDetail.jsx` +- Modify: related driver tests if present + +- [ ] Написать падающие tests на: + - `Доставлен` + - `Проблема доставки` + - возврат логисту после failed delivery +- [ ] Прогнать таргетированные driver/order service tests. +- [ ] Реализовать driver result payload preparation for `n8n`/`1С`. +- [ ] Обновить UI водителя для обязательной фиксации причины неуспешной доставки. + +### Task 10: Завершить ручную ветку логиста и платное хранение + +**Files:** +- Modify: `src/pages/DashboardPage.jsx` +- Modify: `src/components/logistics/BotControlPanel.jsx` +- Modify: `src/services/orderService.js` +- Modify: `src/services/orderService.test.js` + +- [ ] Добавить логистические действия: + - ручное согласование + - перевод в платное хранение + - снятие с платного хранения после новой договоренности +- [ ] Закрепить audit entries/history comments for these transitions. +- [ ] Прогнать order/logistics tests. + +### Task 11: Подготовить обучение и эксплуатационные документы + +**Files:** +- Create: `docs/training/logistics-playbook.md` +- Create: `docs/training/driver-playbook.md` +- Modify: `docs/architecture.md` + +- [ ] Описать сценарии логиста: + - auto agreement monitoring + - manual follow-up + - paid storage + - repeat delivery +- [ ] Описать сценарии водителя: + - confirmed route + - delivered + - failed delivery +- [ ] Обновить architecture doc под публичный client route и integrations layer. + +## Chunk 4: Final Verification And Handoff + +### Task 12: Полная техническая проверка + +**Files:** +- Reference: `docs/superpowers/plans/2026-03-30-delivery-platform-contract-plan.md` + +- [ ] Прогнать unit/integration tests. + - Run: `npm test` + - Expected: PASS +- [ ] Прогнать линтер. + - Run: `npm run lint` + - Expected: PASS +- [ ] Прогнать production build. + - Run: `npm run build` + - Expected: PASS +- [ ] Ручная проверка: + - role login + - demo/dashboard + - public client route + - logistics manual flow + - driver result flow + - integration payload visibility + +### Task 13: Договорная приемка по этапам + +**Files:** +- Reference: contract stage description +- Reference: `docs/training/logistics-playbook.md` +- Reference: `docs/training/driver-playbook.md` + +- [ ] Подготовить demo checklist для Этапа 1. +- [ ] Подготовить production checklist для Этапа 2. +- [ ] Подготовить launch + training checklist для Этапа 3. +- [ ] Зафиксировать, какие входы со стороны Заказчика обязательны до каждой приемки: + - VPS and balance + - domain/subdomain + - SMS provider + - `1С` responsible person + - `1С` data contract diff --git a/docs/superpowers/plans/2026-04-09-email-otp-auth.md b/docs/superpowers/plans/2026-04-09-email-otp-auth.md new file mode 100644 index 0000000..370b023 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-email-otp-auth.md @@ -0,0 +1,253 @@ +# Email OTP Auth Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Включить реальный вход в приложение по одноразовому пинкоду из письма через Supabase только для заранее заведенных пользователей с ролями `admin` и `logistician`. + +**Architecture:** Фронтенд остается на текущем `AuthContext` и `OtpLoginForm`, но demo-подсказки перестают влиять на рабочий auth flow. Supabase Auth отвечает за отправку email OTP и создание session, а приложение после подтверждения кода читает профиль только из `public.users` и `roles`. Новые пользователи через форму не создаются: доступ есть только у тех email, которые заранее заведены в `auth.users` и синхронизированы с `public.users`. + +**Tech Stack:** React 18, Vite, Supabase JS v2, Supabase Auth Email OTP, Supabase SQL, Vitest. + +--- + +## File Structure + +- Modify: `src/context/AuthContext.jsx` — отключить автосоздание пользователей, ужесточить рабочий OTP-flow, обработать отсутствие профиля. +- Modify: `src/context/AuthContext.test.js` — покрыть новую логику для рабочего режима и сохранить demo-фолбэк. +- Modify: `src/components/auth/OtpLoginForm.jsx` — убрать рабочую зависимость от выбора demo-роли и уточнить тексты для OTP-входа. +- Modify: `src/components/auth/OtpLoginForm.test.jsx` — скорректировать ожидания под новый рабочий сценарий. +- Modify: `src/pages/LoginPage.jsx` — оставить линейный сценарий `email -> код -> вход`, без влияния роли в production-режиме. +- Create: `docs/operations/supabase-email-otp-auth.md` — пошаговая настройка Supabase Auth, пользователей и ролей. +- Reference: `src/supabaseClient.js` — проверить, что frontend env уже подаются корректно. +- Reference: `supabase/schema.sql` — использовать существующие `roles`, `users`, `handle_new_user()` и не дублировать auth-механику. + +## Chunk 1: Frontend Auth Flow Tightening + +### Task 1: Зафиксировать ожидаемое поведение auth-flow тестами + +**Files:** +- Modify: `src/context/AuthContext.test.js` +- Modify: `src/components/auth/OtpLoginForm.test.jsx` + +- [ ] **Step 1: Add failing tests for production OTP constraints** + +Добавить проверки на: +- в рабочем режиме email не подменяется demo-значением; +- форма не подсказывает выбор роли как источник прав; +- текст формы объясняет вход по email-коду; +- demo-режим остается рабочим fallback. + +- [ ] **Step 2: Run targeted tests to verify they fail** + +Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx` +Expected: FAIL on outdated demo-oriented behavior. + +- [ ] **Step 3: Keep existing demo tests if still valid** + +Если текущие тесты про `resolveDemoUser` и `resolveLoginEmail` остаются полезными, сохранить их и добавить рядом production-specific expectations вместо полной замены. + +- [ ] **Step 4: Re-run targeted tests after each test update** + +Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx` +Expected: FAIL only for not-yet-implemented UI/auth changes. + +### Task 2: Отключить саморегистрацию через OTP-форму + +**Files:** +- Modify: `src/context/AuthContext.jsx` +- Modify: `src/pages/LoginPage.jsx` + +- [ ] **Step 1: Write the minimal production auth contract** + +В `AuthContext` зафиксировать: +- `requestOtp()` вызывает `supabase.auth.signInWithOtp` +- `options.shouldCreateUser` больше не передается как `true` +- `pendingEmail` остается текущим entered email +- demo-mode behavior не ломается + +- [ ] **Step 2: Make the implementation minimal** + +Изменить рабочий режим так, чтобы: +- логин шел только для уже существующего email; +- новая учетная запись не создавалась автоматически; +- текст ошибки из Supabase пробрасывался наверх без маскировки. + +- [ ] **Step 3: Verify the targeted auth tests** + +Run: `npm test -- src/context/AuthContext.test.js` +Expected: PASS + +- [ ] **Step 4: Sanity-check login screen code** + +Убедиться, что `src/pages/LoginPage.jsx` не передает рабочую роль из UI в production-сценарий и не связывает доступ с выбором `roleHint`. + +### Task 3: Привести OTP-форму к рабочему сценарию доступа по списку email + +**Files:** +- Modify: `src/components/auth/OtpLoginForm.jsx` +- Modify: `src/components/auth/OtpLoginForm.test.jsx` + +- [ ] **Step 1: Remove role selection from the production story** + +Сохранить `roleHint` только как demo-only affordance, но не показывать пользователю, что роль задается при реальном входе. + +- [ ] **Step 2: Update copy for real OTP flow** + +Тексты формы должны говорить: +- email вводится пользователем; +- код приходит на почту; +- доступ определяется учетной записью в системе, а не выбором роли. + +- [ ] **Step 3: Keep demo fallback explicit but isolated** + +Если `isDemoMode === true`, оставить подсказку и поведение demo-режима, но визуально отделить его от реального сценария. + +- [ ] **Step 4: Re-run form tests** + +Run: `npm test -- src/components/auth/OtpLoginForm.test.jsx` +Expected: PASS + +## Chunk 2: Profile Resolution And Access Guard + +### Task 4: Обработать сценарий “auth user есть, профиля в public.users нет” + +**Files:** +- Modify: `src/context/AuthContext.jsx` +- Modify: `src/context/AuthContext.test.js` + +- [ ] **Step 1: Add a failing test for missing profile** + +Проверить сценарий: +- Supabase session создана; +- запрос в `public.users` не нашел профиль или вернул ошибку; +- пользователь не считается успешно авторизованным в приложении без понятной реакции. + +- [ ] **Step 2: Implement minimal guard** + +В `onAuthStateChange`: +- если профиль не найден, не выдавать рабочий `user` в приложение; +- сохранять понятную ошибку или вынуждать повторный вход; +- не падать молча. + +- [ ] **Step 3: Verify tests** + +Run: `npm test -- src/context/AuthContext.test.js` +Expected: PASS + +### Task 5: Уточнить контракт роли и профиля + +**Files:** +- Modify: `src/context/AuthContext.jsx` +- Reference: `supabase/schema.sql` + +- [ ] **Step 1: Verify role source** + +Роль должна читаться только из `public.users -> roles(name)`. + +- [ ] **Step 2: Keep fallback conservative** + +Если роль не пришла, не повышать доступ по умолчанию; допустим fallback только в безопасный минимум (`manager`) либо ошибка доступа, в зависимости от того, что окажется проще и безопаснее после чтения кода. + +- [ ] **Step 3: Re-run auth tests** + +Run: `npm test -- src/context/AuthContext.test.js` +Expected: PASS + +## Chunk 3: Supabase Setup And Data Seeding + +### Task 6: Подготовить рабочую инструкцию по настройке Supabase Auth + +**Files:** +- Create: `docs/operations/supabase-email-otp-auth.md` + +- [ ] **Step 1: Document dashboard settings** + +Описать: +- где включить email OTP; +- что проверить в email auth settings; +- что self-signup должен быть выключен, если используется whitelist-only access. + +- [ ] **Step 2: Document required frontend env** + +Зафиксировать: +- `VITE_SUPABASE_URL` +- `VITE_SUPABASE_ANON_KEY` + +- [ ] **Step 3: Document function secrets separately** + +Коротко пояснить: +- frontend env идут в `.env.local`; +- Edge Function secrets задаются через Supabase secrets/dashboard; +- для OTP-входа frontend не использует `SUPABASE_SERVICE_ROLE_KEY`. + +### Task 7: Подготовить SQL/операционную часть для двух пользователей + +**Files:** +- Create or extend: `docs/operations/supabase-email-otp-auth.md` +- Reference: `supabase/schema.sql` + +- [ ] **Step 1: Describe manual user creation in Supabase Auth** + +Нужно заранее создать: +- `skylanguage@yandex.ru` +- `mk7029953@yandex.ru` + +- [ ] **Step 2: Document role binding in public.users** + +Подготовить SQL-пример или пошаговое описание, как этим пользователям назначить: +- `skylanguage@yandex.ru` -> `admin` +- `mk7029953@yandex.ru` -> `logistician` + +- [ ] **Step 3: Check trigger compatibility** + +Убедиться по схеме, что `handle_new_user()`: +- не ломает вручную заведенных пользователей; +- корректно пишет профиль в `public.users`; +- использует `raw_user_meta_data ->> 'role'`, если оно есть. + +- [ ] **Step 4: Add operator verification checklist** + +Проверка после настройки: +- код приходит на обе почты; +- `admin` видит админский раздел; +- `logistician` видит логистический контур; +- неизвестный email не получает доступ. + +## Chunk 4: End-To-End Verification + +### Task 8: Полная проверка реализации + +**Files:** +- Reference: `src/context/AuthContext.jsx` +- Reference: `src/components/auth/OtpLoginForm.jsx` +- Reference: `src/pages/LoginPage.jsx` +- Reference: `docs/operations/supabase-email-otp-auth.md` + +- [ ] **Step 1: Run focused frontend tests** + +Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx` +Expected: PASS + +- [ ] **Step 2: Run full test suite** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 3: Run linter** + +Run: `npm run lint` +Expected: PASS + +- [ ] **Step 4: Run production build** + +Run: `npm run build` +Expected: PASS + +- [ ] **Step 5: Manual Supabase validation** + +Проверить руками: +- отправка кода на `skylanguage@yandex.ru` +- отправка кода на `mk7029953@yandex.ru` +- успешный вход по пинкоду +- корректная роль после входа +- отказ для email вне whitelist diff --git a/docs/superpowers/plans/2026-04-12-delivery-orchestration-implementation.md b/docs/superpowers/plans/2026-04-12-delivery-orchestration-implementation.md new file mode 100644 index 0000000..c749d60 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-delivery-orchestration-implementation.md @@ -0,0 +1,289 @@ +# Delivery Orchestration Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Реализовать рабочую orchestration-схему согласования доставки на базе `Supabase + Edge Functions + n8n + FTP XML`, включая SMS-тайминги, ручную работу логиста, платное хранение и передачу результата доставки во внешнюю систему. + +**Architecture:** `Supabase` остается источником истины по заказам, приглашениям, статусам и истории. `n8n` становится orchestration-слоем: читает XML с FTP, вычисляет SLA по времени, вызывает `Edge Functions`, отправляет SMS и передает статусы во внешние системы. Приложение показывает оператору и клиенту только актуальное состояние из `Supabase`. + +**Tech Stack:** React 18, Vite, Supabase SQL, Supabase Edge Functions, n8n, FTP XML polling, SMS provider, external accounting integration. + +--- + +## File Structure + +- Create: `docs/integrations/delivery-orchestration.md` — итоговая архитектура процесса и распределение ответственности. +- Create: `docs/integrations/n8n-delivery-flow.md` — рабочие contracts для n8n workflow и payload examples. +- Modify: `docs/architecture.md` — короткая ссылка на orchestration-слой. +- Modify: `docs/scenarios.md` — привести текстовые сценарии к фактической схеме. +- Modify: `supabase/schema.sql` — добавить недостающие orchestration fields, если они еще не добавлены. +- Modify: `supabase/functions/_shared/delivery-invitations.ts` — выровнять helper map под финальную схему. +- Modify: `supabase/functions/create-delivery-invitation/index.ts` +- Modify: `supabase/functions/get-delivery-invitation/index.ts` +- Modify: `supabase/functions/confirm-delivery-choice/index.ts` +- Modify: `supabase/functions/transfer-to-logistics/index.ts` +- Modify: `supabase/functions/report-delivery-result/index.ts` +- Modify: `supabase/functions/README.md` +- Modify: `src/pages/ClientDeliveryPage.jsx` — показать итоговые статусы и ошибки по production flow. +- Modify: `src/components/logistics/BotControlPanel.jsx` — ручные действия логиста. +- Modify: `src/components/driver/DriverDeliveryPlanner.jsx` +- Modify: `src/components/driver/DriverDeliveryDetail.jsx` +- Modify: `src/services/deliveryInvitationApi.js` +- Modify: related tests for functions, client flow, logistics and driver behavior. + +## Chunk 1: Orchestration Contracts + +### Task 1: Зафиксировать архитектурное решение “n8n orchestrates, Supabase stores truth” + +**Files:** +- Create: `docs/integrations/delivery-orchestration.md` +- Modify: `docs/architecture.md` + +- [ ] **Step 1: Write the architecture document** + +Зафиксировать: +- XML на FTP читает `n8n` +- `n8n` запускает сценарии по времени +- `Supabase` хранит статусы и историю +- `Edge Functions` валидируют переходы +- клиентское приложение только читает/подтверждает состояние + +- [ ] **Step 2: Add a short summary into `docs/architecture.md`** + +Добавить отдельный блок про orchestration layer и связь с `n8n`. + +- [ ] **Step 3: Manual doc review** + +Проверить, что документ явно отвечает на вопрос: +- где живет расписание; +- кто читает XML; +- где отправляются SMS; +- кто возвращает статус в учетную систему. + +### Task 2: Подготовить `n8n`-контракт для XML и SLA + +**Files:** +- Create: `docs/integrations/n8n-delivery-flow.md` +- Modify: `docs/scenarios.md` + +- [ ] **Step 1: Describe `FTP XML Poller`** + +Описать: +- как читается XML; +- какие поля заказа считаются сигналом “полностью готов”; +- как `n8n` понимает, что заказ новый для delivery flow. + +- [ ] **Step 2: Describe timeout workflows** + +Зафиксировать: +- первая SMS +- повторная SMS через 3 часа +- передача логисту еще через 3 часа +- reminder через 1 час после opened/no-confirm +- передача логисту через 2 часа после reminder +- SMS о платном хранении + +- [ ] **Step 3: Add payload examples** + +Добавить примеры payload между: +- `n8n -> create-delivery-invitation` +- `n8n -> transfer-to-logistics` +- `n8n -> report-delivery-result` +- `n8n -> SMS provider` + +## Chunk 2: Supabase State Model + +### Task 3: Проверить, что в Supabase хватает данных для SLA-логики + +**Files:** +- Modify: `supabase/schema.sql` +- Modify: `supabase/functions/README.md` + +- [ ] **Step 1: Audit current schema against the flow** + +Проверить наличие/нехватку полей: +- `sent_at` +- `opened_at` +- `confirmed_at` +- `logistics_transferred_at` +- `paid_storage_at` +- `delivered_at` +- reminder markers +- export markers for external system + +- [ ] **Step 2: Add missing fields if required** + +Если чего-то не хватает, добавить минимальные поля без дублирования уже существующих timestamps. + +- [ ] **Step 3: Update function README** + +Описать, какие функции вызываются `n8n` и какие timestamps/statuses они меняют. + +### Task 4: Выровнять Edge Functions под финальную схему + +**Files:** +- Modify: `supabase/functions/_shared/delivery-invitations.ts` +- Modify: `supabase/functions/create-delivery-invitation/index.ts` +- Modify: `supabase/functions/get-delivery-invitation/index.ts` +- Modify: `supabase/functions/confirm-delivery-choice/index.ts` +- Modify: `supabase/functions/transfer-to-logistics/index.ts` +- Modify: `supabase/functions/report-delivery-result/index.ts` +- Test: `supabase/functions/_shared/delivery-invitations.test.ts` +- Test: `supabase/functions/_shared/workflow.test.ts` + +- [ ] **Step 1: Write failing tests for missing state transitions** + +Покрыть: +- awaiting response +- reminder state +- transfer to logistics +- paid storage +- delivered +- failed delivery returning to logistics + +- [ ] **Step 2: Run targeted function tests** + +Run: `npm test -- supabase/functions/_shared/delivery-invitations.test.ts supabase/functions/_shared/workflow.test.ts` +Expected: FAIL if contracts are incomplete. + +- [ ] **Step 3: Implement minimal changes** + +Довести helpers и functions до полного соответствия схеме. + +- [ ] **Step 4: Re-run targeted tests** + +Run: `npm test -- supabase/functions/_shared/delivery-invitations.test.ts supabase/functions/_shared/workflow.test.ts` +Expected: PASS + +## Chunk 3: App Operator Flows + +### Task 5: Довести клиентскую страницу до финального production behavior + +**Files:** +- Modify: `src/pages/ClientDeliveryPage.jsx` +- Modify: `src/components/client/DeliveryChoiceFlow.jsx` +- Modify: `src/components/client/DeliveryStateNotice.jsx` +- Modify: `src/services/deliveryInvitationApi.js` +- Test: `src/components/client/DeliveryChoiceFlow.test.jsx` + +- [ ] **Step 1: Write or extend failing tests for all terminal states** + +Покрыть: +- awaiting choice +- agreed +- transferred to logistics +- paid storage +- delivered +- invalid/expired token + +- [ ] **Step 2: Run targeted client tests** + +Run: `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx` +Expected: FAIL if state rendering is incomplete. + +- [ ] **Step 3: Implement minimal UI changes** + +Сделать экраны и сообщения строго соответствующими схеме. + +- [ ] **Step 4: Re-run client tests** + +Run: `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx` +Expected: PASS + +### Task 6: Довести ручную ветку логиста и ветку водителя + +**Files:** +- Modify: `src/components/logistics/BotControlPanel.jsx` +- Modify: `src/pages/DashboardPage.jsx` +- Modify: `src/components/driver/DriverDeliveryPlanner.jsx` +- Modify: `src/components/driver/DriverDeliveryDetail.jsx` +- Modify: related logistics/driver tests if needed + +- [ ] **Step 1: Add logistics actions** + +Нужны действия: +- согласовать вручную +- перевести в платное хранение +- повторно согласовать после проблемной доставки + +- [ ] **Step 2: Add driver result actions** + +Нужны действия: +- отметить `Доставлен` +- отметить `Проблема доставки` +- передать причину обратно в логистику + +- [ ] **Step 3: Run targeted UI/service tests** + +Run: `npm test -- src/services/orderService.test.js src/services/orderViews.test.js` +Expected: PASS + +## Chunk 4: n8n Implementation And Launch Checklist + +### Task 7: Реализовать сами n8n workflows + +**Files:** +- Reference: `docs/integrations/n8n-delivery-flow.md` + +- [ ] **Step 1: Build `FTP XML Poller` workflow** + +Настроить: +- cron trigger +- чтение XML с FTP +- парсинг готовых заказов +- сверку с `Supabase` + +- [ ] **Step 2: Build `Delivery Offer Dispatcher`** + +Настроить: +- вызов `create-delivery-invitation` +- отправку первой SMS + +- [ ] **Step 3: Build `Delivery Reminder Scheduler`** + +Настроить: +- повторную SMS +- reminder после opened/no-confirm +- передачу логисту по timeout + +- [ ] **Step 4: Build `Paid Storage Notifier`** + +Настроить SMS при статусе `Платное хранение`. + +- [ ] **Step 5: Build `Delivery Result Exporter`** + +Настроить передачу результата доставки во внешнюю систему. + +### Task 8: Финальная проверка + +**Files:** +- Reference: `docs/integrations/delivery-orchestration.md` +- Reference: `docs/integrations/n8n-delivery-flow.md` +- Reference: `docs/superpowers/plans/2026-04-12-delivery-orchestration-implementation.md` + +- [ ] **Step 1: Run full test suite** + +Run: `npm test` +Expected: PASS + +- [ ] **Step 2: Run linter** + +Run: `npm run lint` +Expected: PASS + +- [ ] **Step 3: Run production build** + +Run: `npm run build` +Expected: PASS + +- [ ] **Step 4: Execute manual end-to-end checklist** + +Проверить руками: +- XML отметил заказ как готовый +- ушла первая SMS +- открытие ссылки фиксируется +- reminder отправляется по времени +- заказ уходит логисту при timeout +- логист переводит в согласовано или в платное хранение +- водитель фиксирует доставку или проблему +- итоговый статус уходит во внешнюю систему diff --git a/docs/superpowers/plans/2026-04-13-1c-delivery-frontend-supabase.md b/docs/superpowers/plans/2026-04-13-1c-delivery-frontend-supabase.md new file mode 100644 index 0000000..d129c90 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-1c-delivery-frontend-supabase.md @@ -0,0 +1,283 @@ +# 1C Delivery Frontend And Supabase Preparation Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prepare the frontend and Supabase data model for a delivery-only workflow where orders are imported from 1C, delivery automation starts only after the full client order set is ready, and logistician, driver, and client interfaces work against live Supabase data. + +**Architecture:** 1C remains the source of order creation and production progress. Supabase becomes the source of truth for delivery workflow state, imported source fields, grouped client delivery sets, invitations, slots, and operator actions. The frontend stops behaving like a generic order-management ERP panel and becomes a delivery workspace centered on logisticians, drivers, admins, and the public client delivery page. + +**Tech Stack:** React, Vite, Supabase Auth, Supabase Postgres, existing order repositories and hooks, delivery invitation API, OTP email login. + +--- + +## File Structure + +**Existing files to modify** +- `src/pages/LoginPage.jsx` +- `src/components/auth/OtpLoginForm.jsx` +- `src/constants/roles.js` +- `src/constants/deliveryWorkflow.js` +- `src/services/supabase/orderRepository.js` +- `src/services/supabase/orderRepository.test.js` +- `src/hooks/useOrders.js` +- `src/pages/DashboardPage.jsx` +- `src/components/dashboard/RoleWorkspacePanel.jsx` +- `src/components/logistics/BotControlPanel.jsx` +- `src/components/orders/OrderDetailPanel.jsx` +- `src/components/driver/DriverDeliveryPlanner.jsx` +- `src/components/driver/DriverDeliveryDetail.jsx` +- `src/pages/ClientDeliveryPage.jsx` +- `src/components/client/DeliveryChoiceFlow.jsx` +- `src/components/client/DeliveryStateNotice.jsx` +- `supabase/schema.sql` +- `supabase/seed/stage-1-demo.sql` +- `docs/scenarios.md` +- `docs/architecture.md` + +**New files to create** +- `src/services/deliverySetViews.js` +- `src/services/deliverySetViews.test.js` +- `src/components/logistics/LogisticsReadinessBoard.jsx` +- `src/components/logistics/LogisticsReadinessBoard.test.jsx` +- `src/components/logistics/DeliverySetDetailPanel.jsx` +- `src/components/logistics/DeliverySetDetailPanel.test.jsx` +- `src/components/client/DeliverySlotsPicker.jsx` +- `src/components/client/DeliverySlotsPicker.test.jsx` +- `docs/superpowers/specs/2026-04-13-1c-delivery-ui-design.md` + +--- + +## Product Rules To Preserve + +- Orders are created and progressed in `1С`, not in the web app. +- The new delivery flow must ignore the legacy `SMS` field from XML as a business trigger. +- Delivery automation must start only when the full client order set is ready. +- Readiness is evaluated per client delivery set, not per single imported order row. +- The web app is a delivery workspace for `logistician`, `driver`, and `admin`. +- The client is not an authenticated app user. The client uses a public invitation link. + +--- + +## Delivery Data Model Direction + +### Imported order-level fields + +`public.orders` should carry imported 1C fields needed for UI and later automation: + +- `source_order_number` +- `source_order_date` +- `source_customer_name` +- `source_customer_phone` +- `source_customer_email` +- `source_customer_city` +- `source_total_sum` +- `source_paid_at` +- `source_gateway` +- `source_associated_bills_text` +- `source_production_at` +- `source_saw_at` +- `source_glue_at` +- `source_h_glue_at` +- `source_curve_at` +- `source_accept_at` +- `source_ship_at` +- `source_payload jsonb` + +### Delivery-set fields + +`public.orders` also needs delivery-specific derived fields: + +- `delivery_set_key` +- `delivery_set_name` +- `delivery_set_status` +- `delivery_set_ready_at` +- `delivery_ready_reason` +- `source_sms_legacy_at` + +### Delivery-set rule + +For the current phase, the canonical readiness rule should be: + +- A single imported order is ready when `source_accept_at` is present and `source_ship_at` is absent. +- A client delivery set is ready only when all linked imported orders in the set are ready. +- The legacy XML `SMS` field is stored only as historical source data and must not start the new scenario. + +### Grouping rule + +Until real `n8n` grouping is finished, Supabase demo data and frontend logic should use: + +1. explicit `delivery_set_key` from imported or seeded data +2. fallback grouping by normalized client phone and client name +3. linked order numbers from `source_associated_bills_text` as supporting context + +--- + +## Chunk 1: Lock The Delivery Product Model + +### Task 1: Reframe roles and statuses around delivery instead of order creation + +**Files:** +- Modify: `src/constants/roles.js` +- Modify: `src/constants/deliveryWorkflow.js` +- Test: `src/constants/deliveryWorkflow.contract.test.js` + +- [ ] **Step 1: Write failing contract assertions for delivery-centric wording and ownership** +- [ ] **Step 2: Run `npm test -- src/constants/deliveryWorkflow.contract.test.js` and confirm failure** +- [ ] **Step 3: Update role labels and permission text for `logistician`, `driver`, and `admin`** +- [ ] **Step 4: Update workflow comments, stage labels, and ownership so the flow is based on imported 1C orders** +- [ ] **Step 5: Re-run `npm test -- src/constants/deliveryWorkflow.contract.test.js` and confirm pass** +- [ ] **Step 6: Commit** + +--- + +## Chunk 2: Prepare Supabase For 1C-Shaped Delivery Data + +### Task 2: Extend schema for imported source fields and delivery-set state + +**Files:** +- Modify: `supabase/schema.sql` +- Modify: `supabase/seed/stage-1-demo.sql` + +- [ ] **Step 1: Add idempotent schema columns for source order fields, production-step timestamps, and delivery-set state** +- [ ] **Step 2: Add indexes for `delivery_set_key`, `delivery_set_status`, `source_accept_at`, and `source_ship_at`** +- [ ] **Step 3: Rewrite the seed so demo data represents grouped imported orders rather than hand-entered app orders** +- [ ] **Step 4: Add SQL comments documenting that `source_sms_legacy_at` is informational only** +- [ ] **Step 5: Review that schema can run before seed without missing-column failures** +- [ ] **Step 6: Commit** + +--- + +## Chunk 3: Build Frontend Read Models Around Delivery Sets + +### Task 3: Add delivery-set view helpers + +**Files:** +- Create: `src/services/deliverySetViews.js` +- Create: `src/services/deliverySetViews.test.js` +- Modify: `src/services/supabase/orderRepository.js` +- Modify: `src/services/supabase/orderRepository.test.js` +- Modify: `src/hooks/useOrders.js` + +- [ ] **Step 1: Write failing tests for grouping imported orders into delivery sets** +- [ ] **Step 2: Run `npm test -- src/services/deliverySetViews.test.js src/services/supabase/orderRepository.test.js` and confirm failure** +- [ ] **Step 3: Implement pure grouping helpers for readiness, buckets, and linked-order summaries** +- [ ] **Step 4: Extend repository output with source-field summary and delivery-set metadata** +- [ ] **Step 5: Update `useOrders` to expose grouped delivery-set buckets** +- [ ] **Step 6: Re-run `npm test -- src/services/deliverySetViews.test.js src/services/supabase/orderRepository.test.js` and confirm pass** +- [ ] **Step 7: Commit** + +--- + +## Chunk 4: Turn The Dashboard Into A Logistics Workspace + +### Task 4: Replace generic order emphasis with logistics-first sections + +**Files:** +- Modify: `src/pages/DashboardPage.jsx` +- Modify: `src/components/dashboard/RoleWorkspacePanel.jsx` +- Create: `src/components/logistics/LogisticsReadinessBoard.jsx` +- Create: `src/components/logistics/LogisticsReadinessBoard.test.jsx` +- Create: `src/components/logistics/DeliverySetDetailPanel.jsx` +- Create: `src/components/logistics/DeliverySetDetailPanel.test.jsx` + +- [ ] **Step 1: Write failing UI tests for logistics buckets and delivery-set cards** +- [ ] **Step 2: Run `npm test -- src/components/logistics/LogisticsReadinessBoard.test.jsx src/components/logistics/DeliverySetDetailPanel.test.jsx` and confirm failure** +- [ ] **Step 3: Build `LogisticsReadinessBoard` with sections `На подходе`, `Готово к запуску`, `Ожидает клиента`, `Нужна ручная работа`, `Согласовано`, `Завершено`** +- [ ] **Step 4: Build `DeliverySetDetailPanel` with linked source orders, source stages, slot state, and manual action placeholders** +- [ ] **Step 5: Integrate the new board into `DashboardPage` so `logistician` lands in a logistics-first workspace** +- [ ] **Step 6: Update `RoleWorkspacePanel` to use grouped delivery-set counts** +- [ ] **Step 7: Re-run `npm test -- src/components/logistics/LogisticsReadinessBoard.test.jsx src/components/logistics/DeliverySetDetailPanel.test.jsx` and confirm pass** +- [ ] **Step 8: Commit** + +--- + +## Chunk 5: Tighten OTP Entry For Real Operator Use + +### Task 5: Polish login UX and unknown-email handling + +**Files:** +- Modify: `src/pages/LoginPage.jsx` +- Modify: `src/components/auth/OtpLoginForm.jsx` +- Modify: `src/context/AuthContext.jsx` +- Test: `src/components/auth/OtpLoginForm.test.jsx` +- Test: `src/context/AuthContext.test.js` + +- [ ] **Step 1: Add failing tests for inbox and spam helper text and the unknown-email message** +- [ ] **Step 2: Run `npm test -- src/components/auth/OtpLoginForm.test.jsx src/context/AuthContext.test.js` and confirm failure** +- [ ] **Step 3: Update login copy so operators are told to check inbox and `Spam`** +- [ ] **Step 4: Keep strict existing-user-only auth and map unknown email to `Email не найден в системе. Обратитесь к администратору.`** +- [ ] **Step 5: Re-run `npm test -- src/components/auth/OtpLoginForm.test.jsx src/context/AuthContext.test.js` and confirm pass** +- [ ] **Step 6: Commit** + +--- + +## Chunk 6: Make Client And Driver Screens Match The Real Flow + +### Task 6: Prepare client delivery page and driver workspace for live demo + +**Files:** +- Modify: `src/pages/ClientDeliveryPage.jsx` +- Modify: `src/components/client/DeliveryChoiceFlow.jsx` +- Modify: `src/components/client/DeliveryStateNotice.jsx` +- Create: `src/components/client/DeliverySlotsPicker.jsx` +- Create: `src/components/client/DeliverySlotsPicker.test.jsx` +- Modify: `src/components/driver/DriverDeliveryPlanner.jsx` +- Modify: `src/components/driver/DriverDeliveryDetail.jsx` +- Test: `src/components/client/DeliveryChoiceFlow.test.jsx` + +- [ ] **Step 1: Write failing tests for slot rendering and read-only state notices** +- [ ] **Step 2: Run `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx src/components/client/DeliverySlotsPicker.test.jsx` and confirm failure** +- [ ] **Step 3: Build `DeliverySlotsPicker` for date plus half-day choices from live slot data** +- [ ] **Step 4: Refine the public client page around invitation states and clear delivery-choice messaging** +- [ ] **Step 5: Refine driver screens to focus on assigned deliveries, slot, city, and result actions** +- [ ] **Step 6: Re-run `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx src/components/client/DeliverySlotsPicker.test.jsx` and confirm pass** +- [ ] **Step 7: Commit** + +--- + +## Chunk 7: Update Documentation And Demo Script + +### Task 7: Make the product story match the real workflow + +**Files:** +- Modify: `docs/architecture.md` +- Modify: `docs/scenarios.md` +- Create: `docs/superpowers/specs/2026-04-13-1c-delivery-ui-design.md` + +- [ ] **Step 1: Write the design/spec doc for the 1C-driven delivery UI** +- [ ] **Step 2: Update `docs/architecture.md` to describe 1C import, Supabase delivery truth, and later n8n orchestration** +- [ ] **Step 3: Update `docs/scenarios.md` so the story is operator login -> logistician workspace -> client link -> driver completion** +- [ ] **Step 4: Add a short demo checklist for the first paid milestone** +- [ ] **Step 5: Commit** + +--- + +## Verification + +After each chunk, run the smallest relevant tests first. Before final completion, run: + +```bash +npm test +npm run lint +npm run build +``` + +Also verify manually: +- unknown email shows the admin-help message +- login copy mentions inbox and spam +- logistician lands in a logistics-first workspace +- a ready delivery set is shown as one grouped client unit +- the client page shows live slot options +- the driver page shows assigned deliveries and result actions + +--- + +## Out Of Scope For This Plan + +- Real `n8n` workflow implementation +- FTP/XML polling automation +- Real 1C write-back integration +- Route optimization, truck loading, or waybill generation +- Changes to internal 1C production logic + +This plan prepares the frontend and Supabase so those integrations can be connected later without redesigning the product again. diff --git a/docs/superpowers/plans/2026-04-13-stage-1-supabase-demo.md b/docs/superpowers/plans/2026-04-13-stage-1-supabase-demo.md new file mode 100644 index 0000000..33e3d11 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-stage-1-supabase-demo.md @@ -0,0 +1,347 @@ +# Stage 1 Supabase Demo Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Довести первый этап проекта до живой демонстрации на `Supabase`: вход по email OTP только для существующих пользователей, ролевой доступ, данные заказов и пользователей из базы, экран логиста и публичная клиентская страница по реальному invitation token. + +**Architecture:** `Supabase` становится источником истины для пользователей, ролей, заказов, истории, delivery slots и invitation state. Фронтенд постепенно уходит от `demoUsers/demoOrders`: для первого этапа мы переводим auth и ключевые read-only экраны на живые данные из базы, а существующие UI-компоненты адаптируем так, чтобы они принимали `users/orders` из `Supabase` вместо жестко вшитых mock-объектов. + +**Tech Stack:** React 18, Vite, Supabase Auth Email OTP, Supabase SQL, Supabase JS v2, Edge Functions, Vitest. + +--- + +## File Structure + +- Modify: `src/context/AuthContext.jsx` — запретить автосоздание пользователей, нормализовать ошибку для email вне whitelist, сохранить рабочий OTP flow. +- Modify: `src/context/AuthContext.test.js` — покрыть `shouldCreateUser: false` и понятную ошибку для неизвестного email. +- Modify: `src/pages/LoginPage.jsx` — сохранить линейный сценарий `email -> код -> вход`, показывать нормализованные ошибки доступа. +- Modify: `src/components/auth/OtpLoginForm.jsx` — оставить рабочий production-copy без смешения с demo-ролью. +- Create: `src/services/supabase/userRepository.js` — чтение пользователей и ролей из `public.users`. +- Modify: `src/services/supabase/orderRepository.js` — добавить mapper из raw Supabase rows в UI order model. +- Modify: `src/hooks/useOrders.js` — загружать заказы и пользователей из `Supabase`, а mock-данные использовать только как fallback. +- Modify: `src/pages/DashboardPage.jsx` — передавать живые `users` и `audit events` в дочерние панели. +- Modify: `src/components/admin/UserDirectoryPanel.jsx` — показывать пользователей из базы. +- Modify: `src/components/orders/OrderFilters.jsx` — строить списки менеджеров и логистов из живых данных. +- Modify: `src/components/orders/OrdersTable.jsx` — резолвить имена через переданный `users` map. +- Modify: `src/components/orders/OrderDetailPanel.jsx` — резолвить менеджера/логиста/водителя через `users` из `Supabase`. +- Modify: `src/components/orders/OrderEditorPanel.jsx` — использовать живых менеджеров/логистов/водителей. +- Modify: `src/components/driver/DriverDeliveryDetail.jsx` — убрать зависимость от `demoUsers`. +- Modify: `src/components/admin/AuditPanel.jsx` — по возможности читать `integration_events`; если не хватает данных, хотя бы показывать order history и live notifications. +- Modify: `src/pages/ClientDeliveryPage.jsx` — убедиться, что production flow работает с реальным invitation из `Edge Functions`. +- Modify: `src/services/deliveryInvitationApi.js` — сохранить production-контракт, при необходимости привести payload под фактическую функцию. +- Create: `supabase/seed/stage-1-demo.sql` — готовый SQL для seed-данных первого этапа: профили пользователей, заказы, история, слоты, чат, invitation, integration events. +- Create: `docs/operations/stage-1-demo-script.md` — что показать заказчику на демонстрации первого этапа и в каком порядке. + +## Chunk 1: OTP Auth For Existing Users Only + +### Task 1: Зафиксировать тестами whitelist-only OTP behavior + +**Files:** +- Modify: `src/context/AuthContext.test.js` +- Modify: `src/components/auth/OtpLoginForm.test.jsx` + +- [ ] **Step 1: Add failing auth expectations** + +Покрыть: +- `buildOtpRequestPayload()` возвращает `options.shouldCreateUser = false`. +- неизвестный email преобразуется в понятную ошибку для UI. +- существующие тесты demo fallback не ломаются. + +- [ ] **Step 2: Run targeted tests to verify current mismatch** + +Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx` +Expected: FAIL как минимум на `shouldCreateUser: false`, если код еще не приведен к тестам. + +- [ ] **Step 3: Keep production and demo scenarios isolated in tests** + +Явно разделить тесты на: +- production OTP +- demo fallback + +### Task 2: Реализовать production auth без автосоздания пользователей + +**Files:** +- Modify: `src/context/AuthContext.jsx` +- Modify: `src/pages/LoginPage.jsx` +- Modify: `src/components/auth/OtpLoginForm.jsx` + +- [ ] **Step 1: Disable automatic user creation** + +В `buildOtpRequestPayload()` вернуть: + +```js +{ + email, + options: { + shouldCreateUser: false, + }, +} +``` + +- [ ] **Step 2: Add a normal UI error for unknown email** + +Добавить helper наподобие `normalizeOtpError(error)` и свести типовые ошибки Supabase (`user not found`, `signups not allowed`, `signup disabled`) к сообщению: + +```text +Email не найден в системе. Обратитесь к администратору. +``` + +- [ ] **Step 3: Keep missing-profile guard explicit** + +Если session создана, но в `public.users` нет профиля, оставлять: + +```text +Профиль пользователя не найден. Обратитесь к администратору. +``` + +- [ ] **Step 4: Re-run auth tests** + +Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx` +Expected: PASS + +## Chunk 2: Supabase As The Read Model For Stage 1 Demo + +### Task 3: Подготовить user repository и общий mapper для UI + +**Files:** +- Create: `src/services/supabase/userRepository.js` +- Modify: `src/services/supabase/orderRepository.js` +- Test: `src/services/supabase/orderRepository.test.js` + +- [ ] **Step 1: Add user fetch function** + +В `userRepository` реализовать чтение: +- `id` +- `email` +- `name` +- `last_login` +- `role_info:roles(name)` + +- [ ] **Step 2: Add order row -> UI model mapper** + +`orderRepository` должен уметь превращать raw row из `orders + order_history + delivery_slots + chat_messages` в UI-совместимую модель: + +```js +{ + id, + orderNumber, + customer, + status, + deliveryAgreementStatus, + managerId, + logisticianIds, + assignedDriverId, + createdAt, + updatedAt, + history, + chatMessages, + deliverySlots, + orderNotes, + internalMessages, + items, + comments, + tags, +} +``` + +- [ ] **Step 3: Extend repository tests** + +Покрыть mapper отдельно, чтобы не ломать UI при несовпадении snake_case/camelCase. + +- [ ] **Step 4: Run targeted repository tests** + +Run: `npm test -- src/services/supabase/orderRepository.test.js` +Expected: PASS + +### Task 4: Перевести `useOrders` на Supabase-backed loading + +**Files:** +- Modify: `src/hooks/useOrders.js` +- Modify: `src/pages/DashboardPage.jsx` + +- [ ] **Step 1: Add async initial loading** + +Если `hasSupabaseConfig === true`, загружать: +- `users` +- `orders` +- при наличии — `integration_events` + +Если `Supabase` недоступен, только тогда падать обратно в `demoOrders/demoUsers`. + +- [ ] **Step 2: Keep current local mutation API stable** + +Вернуть из `useOrders` не только `orders`, но и: +- `users` +- `userMap` +- `isSupabaseBacked` +- `isLoading` +- `loadError` + +При этом не ломать текущие callbacks (`updateStatus`, `assignDriver`, etc.) — для первого этапа допускается локальная мутация поверх загруженных данных, если запись в базу еще не вся реализована. + +- [ ] **Step 3: Pass users into dashboard child panels** + +`DashboardPage` должен передавать `users` в те компоненты, которые раньше читали `demoUsers` напрямую. + +- [ ] **Step 4: Run the main UI tests that touch dashboard data** + +Run: `npm test -- src/services/orderService.test.js src/services/orderViews.test.js src/layouts/AppShell.test.jsx` +Expected: PASS + +## Chunk 3: Remove Frontend Dependence On `demoUsers` For Key Demo Screens + +### Task 5: Перевести админскую и заказную часть на живых пользователей + +**Files:** +- Modify: `src/components/admin/UserDirectoryPanel.jsx` +- Modify: `src/components/orders/OrderFilters.jsx` +- Modify: `src/components/orders/OrdersTable.jsx` +- Modify: `src/components/orders/OrderDetailPanel.jsx` +- Modify: `src/components/orders/OrderEditorPanel.jsx` +- Modify: `src/components/driver/DriverDeliveryDetail.jsx` + +- [ ] **Step 1: Replace direct `demoUsers` imports with props** + +Каждый из этих компонентов должен принимать `users` или `userMap` из родителя. + +- [ ] **Step 2: Add stable helpers** + +Выделить helper вида: + +```js +const resolveUserName = (userMap, userId) => userMap[userId]?.name || "Не назначен"; +``` + +чтобы не дублировать логику по компонентам. + +- [ ] **Step 3: Keep empty-state behavior usable** + +Если пользователей еще нет в базе, экран должен оставаться рабочим и показывать нейтральные подписи, а не падать. + +- [ ] **Step 4: Re-run relevant component tests** + +Run: `npm test -- src/components/orders/OrdersTable.test.jsx src/components/orders/OrderDetailPanel.test.jsx src/components/orders/OrderFilters.test.jsx` +Expected: PASS + +### Task 6: Довести логистический read-side для демонстрации первого этапа + +**Files:** +- Modify: `src/pages/DashboardPage.jsx` +- Modify: `src/components/logistics/BotControlPanel.jsx` +- Modify: `src/components/dashboard/RoleWorkspacePanel.jsx` + +- [ ] **Step 1: Ensure logistician sees live assigned orders** + +Логист должен видеть из `Supabase`: +- заказы в `Готов к отгрузке` +- `Ожидает ответа клиента` +- `Передан логисту` +- `Доставка согласована` +- `Платное хранение` + +- [ ] **Step 2: Keep first-stage scope limited** + +Не уходить пока в полную orchestration automation. Для демонстрации достаточно: +- видеть список заказов; +- открыть карточку; +- увидеть слоты, историю и текущий статус; +- показать, что клиентская ссылка существует. + +- [ ] **Step 3: Add a visible source marker** + +Если данные идут из живого `Supabase`, показывать в логистической зоне небольшой статус вроде `Live Supabase data`, чтобы на демо было видно отличие от моков. + +## Chunk 4: Ready SQL For Stage 1 Demo + +### Task 7: Подготовить полный seed SQL для демонстрации первого этапа + +**Files:** +- Create: `supabase/seed/stage-1-demo.sql` + +- [ ] **Step 1: Seed role bindings for real auth users** + +SQL должен: +- находить `auth.users` по email; +- upsert-ить записи в `public.users`; +- назначать роли: + - `skylanguage@yandex.ru` -> `admin` + - `mk7029953@yandex.ru` -> `logistician` + +- [ ] **Step 2: Seed at least 4 representative orders** + +Добавить набор, покрывающий demo-показ: +- заказ `Готов к отгрузке` +- заказ `Ожидает ответа клиента` +- заказ `Доставка согласована` +- заказ `Передан логисту` или `Платное хранение` + +- [ ] **Step 3: Seed order history, slots and chat** + +Для каждого demo order добавить: +- `order_history` +- `delivery_slots` +- минимум 1-2 `chat_messages` +- при необходимости `integration_events` + +- [ ] **Step 4: Seed one working public invitation** + +Добавить один demo invitation с известным тестовым токеном/хэшем так, чтобы можно было показать клиентскую ссылку через `get-delivery-invitation`. + +- [ ] **Step 5: Make SQL re-runnable** + +Использовать `delete/insert` или `upsert`, чтобы скрипт можно было запускать повторно без ручной чистки. + +## Chunk 5: Demo Script And Verification + +### Task 8: Подготовить сценарий показа первого этапа + +**Files:** +- Create: `docs/operations/stage-1-demo-script.md` + +- [ ] **Step 1: Describe the demo sequence** + +Порядок показа: +1. вход по email OTP; +2. ошибка для неизвестного email; +3. вход как `admin`; +4. обзор пользователей и ролей; +5. вход как `logistician`; +6. обзор заказов и карточки; +7. открытие публичной ссылки клиента; +8. показ выбора даты/времени и terminal states. + +- [ ] **Step 2: Add exact demo URLs and entities** + +Зафиксировать: +- URL логина +- URL dashboard +- URL публичной client page +- order numbers, которые нужно открывать на встрече + +### Task 9: Финальная верификация реализации + +**Files:** +- Modify as needed after testing + +- [ ] **Step 1: Run targeted tests** + +Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx src/services/supabase/orderRepository.test.js src/components/orders/OrdersTable.test.jsx src/components/orders/OrderDetailPanel.test.jsx src/components/orders/OrderFilters.test.jsx` +Expected: PASS + +- [ ] **Step 2: Run lint** + +Run: `npm run lint` +Expected: PASS + +- [ ] **Step 3: Run production build** + +Run: `npm run build` +Expected: PASS + +- [ ] **Step 4: Manual verification in browser** + +Проверить руками: +- неизвестный email получает сообщение “обратитесь к администратору”; +- `admin` заходит и видит свои панели; +- `logistician` заходит и видит живые заказы из `Supabase`; +- публичная ссылка клиента открывается и показывает корректный state. diff --git a/docs/superpowers/skills/brainstorming/SKILL.md b/docs/superpowers/skills/brainstorming/SKILL.md new file mode 100644 index 0000000..aada0d1 --- /dev/null +++ b/docs/superpowers/skills/brainstorming/SKILL.md @@ -0,0 +1,99 @@ +--- +name: brainstorming +description: "You MUST use this before any creative work — creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation." +--- + +# Brainstorming Ideas Into Designs + +Help turn ideas into fully formed designs and specs through natural collaborative dialogue. Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval. + +Do NOT write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. + +## Anti-Pattern: "This Is Too Simple To Need A Design" +Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval. + +## Checklist + +You MUST create a task for each of these items and complete them in order: + +1. **Explore project context** — check files, docs, recent commits +2. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria +3. **Propose 2-3 approaches** — with trade-offs and your recommendation +4. **Present design** — in sections scaled to their complexity, get user approval after each section +5. **Write design doc** — save to `docs/superpowers/specs/YYYY-MM-DD--design.md` and commit +6. **Spec self-review** — quick inline check for placeholders, contradictions, ambiguity, scope +7. **User reviews written spec** — ask user to review the spec file before proceeding +8. **Transition to implementation** — invoke writing-plans skill to create implementation plan + +## The Process + +**Understanding the idea:** +- Check out the current project state first (files, docs, recent commits in `src/`, `docs/`, `supabase/`) +- Before asking detailed questions, assess scope: if the request describes multiple independent subsystems, flag this immediately +- If the project is too large for a single spec, help the user decompose into sub-projects +- For appropriately-scoped projects, ask questions one at a time to refine the idea +- Prefer multiple choice questions when possible, but open-ended is fine too +- Only one question per message +- Focus on understanding: purpose, constraints, success criteria + +**Exploring approaches:** +- Propose 2-3 different approaches with trade-offs +- Present options conversationally with your recommendation and reasoning +- Lead with your recommended option and explain why + +**Presenting the design:** +- Once you believe you understand what you're building, present the design +- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced +- Ask after each section whether it looks right so far +- Cover: architecture, components, data flow, error handling, testing +- Be ready to go back and clarify if something doesn't make sense + +**Design for isolation and clarity:** +- Break the system into smaller units that each have one clear purpose +- For each unit, you should be able to answer: what does it do, how do you use it, and what does it depend on? +- Can someone understand what a unit does without reading its internals? +- Smaller, well-bounded units are easier to reason about + +**Working in existing codebases:** +- Explore the current structure before proposing changes. Follow existing patterns. +- This project uses: React 18, React Router 7, Tailwind CSS, Framer Motion, Supabase +- Service layer in `src/services/` with pure functions, Supabase adapters in `src/services/supabase/` +- Components organized by domain: `orders/`, `dashboard/`, `logistics/`, `admin/`, `driver/` +- Context providers in `src/context/` for auth and theme +- Where existing code has problems that affect the work, include targeted improvements as part of the design +- Don't propose unrelated refactoring. Stay focused on what serves the current goal. + +## After the Design + +**Documentation:** +- Write the validated design (spec) to `docs/superpowers/specs/YYYY-MM-DD--design.md` +- Commit the design document to git + +**Spec Self-Review:** +After writing the spec document, look at it with fresh eyes: +1. **Placeholder scan:** Any "TBD", "TODO", incomplete sections? Fix them. +2. **Internal consistency:** Do any sections contradict each other? +3. **Scope check:** Is this focused enough for a single implementation plan? +4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit. + +Fix any issues inline. No need to re-review — just fix and move on. + +**User Review Gate:** +After the spec review loop passes, ask the user to review the written spec before proceeding: + +> "Spec written and committed to `[path]`. Please review it and let me know if you want to make any changes before we start writing out the implementation plan." + +Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves. + +**Implementation:** +- Invoke the writing-plans skill to create a detailed implementation plan +- Do NOT invoke any other skill. writing-plans is the next step. + +## Key Principles + +- **One question at a time** — Don't overwhelm with multiple questions +- **Multiple choice preferred** — Easier to answer than open-ended when possible +- **YAGNI ruthlessly** — Remove unnecessary features from all designs +- **Explore alternatives** — Always propose 2-3 approaches before settling +- **Incremental validation** — Present design, get approval before moving on +- **Be flexible** — Go back and clarify when something doesn't make sense diff --git a/docs/superpowers/skills/executing-plans/SKILL.md b/docs/superpowers/skills/executing-plans/SKILL.md new file mode 100644 index 0000000..97419c9 --- /dev/null +++ b/docs/superpowers/skills/executing-plans/SKILL.md @@ -0,0 +1,67 @@ +--- +name: executing-plans +description: Use when you have a written implementation plan to execute in the current session with review checkpoints +--- + +# Executing Plans + +## Overview +Load plan, review critically, execute all tasks, report when complete. + +**Announce at start:** "I'm using the executing-plans skill to implement this plan." + +## The Process + +### Step 1: Load and Review Plan + +1. Read plan file from `docs/superpowers/plans/` +2. Review critically — identify any questions or concerns +3. If concerns: Raise them with your human partner before starting +4. If no concerns: Create TodoWrite and proceed + +### Step 2: Execute Tasks + +For each task: +1. Mark as `in_progress` +2. Follow each step exactly (plan has bite-sized steps) +3. Run verifications as specified +4. Mark as `completed` + +After each task, run verification: + +```bash +npm run test # Verify tests pass +npm run lint # Verify lint clean +``` + +### Step 3: Complete Development + +After all tasks complete and verified: +- Announce: "I'm using the finishing-a-development-branch skill to complete this work." +- **REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` +- Follow that skill to verify tests, present options, execute choice + +## When to Stop and Ask for Help + +**STOP executing immediately when:** +- Hit a blocker (missing dependency, test fails, instruction unclear) +- Plan has critical gaps preventing starting +- You don't understand an instruction +- Verification fails repeatedly + +**Ask for clarification rather than guessing.** + +## Remember + +- Review plan critically first +- Follow plan steps exactly +- Don't skip verifications +- Stop when blocked, don't guess +- Never start implementation on main/master branch without explicit user consent + +## Integration + +**Required workflow skills:** +- **superpowers:using-git-worktrees** — REQUIRED: Set up isolated workspace before starting +- **superpowers:writing-plans** — Creates the plan this skill executes +- **superpowers:finishing-a-development-branch** — Complete development after all tasks diff --git a/docs/superpowers/skills/finishing-a-development-branch/SKILL.md b/docs/superpowers/skills/finishing-a-development-branch/SKILL.md new file mode 100644 index 0000000..4804349 --- /dev/null +++ b/docs/superpowers/skills/finishing-a-development-branch/SKILL.md @@ -0,0 +1,167 @@ +--- +name: finishing-a-development-branch +description: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work +--- + +# Finishing a Development Branch + +## Overview +Guide completion of development work by presenting clear options and handling chosen workflow. + +**Core principle:** Verify tests → Present options → Execute choice → Clean up. + +**Announce at start:** "I'm using the finishing-a-development-branch skill to complete this work." + +## The Process + +### Step 1: Verify Tests + +**Before presenting options, verify tests pass:** + +```bash +npm run test +``` + +**If tests fail:** +``` +Tests failing. Must fix before completing: [Show failures] +Cannot proceed with merge/PR until tests pass. +``` +Stop. Don't proceed to Step 2. + +**If tests pass:** Continue to Step 2. + +Also run lint and build checks: + +```bash +npm run lint +npm run build +``` + +### Step 2: Determine Base Branch + +```bash +git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null +``` + +Or ask: "This branch split from `main` — is that correct?" + +### Step 3: Present Options + +Present exactly these 4 options: + +``` +Implementation complete. What would you like to do? + +1. Merge back to main locally +2. Push and create a Pull Request +3. Keep the branch as-is (I'll handle it later) +4. Discard this work + +Which option? +``` + +### Step 4: Execute Choice + +#### Option 1: Merge Locally + +```bash +git checkout main +git pull origin main +git merge +git push origin main +git branch -d +``` + +Then: Cleanup worktree (Step 5) + +#### Option 2: Push and Create PR + +```bash +git push -u origin +gh pr create --title "" --body "$(cat <<'EOF' +## Summary +<2-3 bullets of what changed> +## Test Plan +- [ ] <verification steps> +EOF +)" +``` + +Then: Cleanup worktree if applicable (Step 5) + +#### Option 3: Keep As-Is + +Report: "Keeping branch `<name>`. Worktree preserved at `<path>`." + +**Don't cleanup worktree.** + +#### Option 4: Discard + +**Confirm first:** + +``` +This will permanently delete: +- Branch <name> +- All commits: <commit-list> +- Worktree at <path> +Type 'discard' to confirm. +``` + +Wait for exact confirmation. If confirmed: + +```bash +git checkout main +git branch -D <feature-branch> +``` + +Then: Cleanup worktree (Step 5) + +### Step 5: Cleanup Worktree + +Check if in worktree: + +```bash +git worktree list | grep $(git branch --show-current) +``` + +If yes, remove it: + +```bash +git worktree remove <worktree-path> +``` + +**For Option 3 (Keep As-Is):** Keep worktree, don't cleanup. + +## Quick Reference + +| Option | Merge | Push | Keep Worktree | Cleanup Branch | +|--------|-------|------|---------------|----------------| +| 1. Merge locally | ✓ | - | - | ✓ | +| 2. Create PR | - | ✓ | ✓ | - | +| 3. Keep as-is | - | - | ✓ | - | +| 4. Discard | - | - | - | ✓ (force) | + +## Red Flags + +**Never:** +- Proceed with failing tests +- Merge without verifying tests on result +- Delete work without confirmation +- Force-push without explicit request + +**Always:** +- Verify tests before offering options +- Run lint and build checks before offering options +- Present exactly 4 options +- Get typed confirmation for Option 4 +- Clean up worktree for Options 1 & 4 only + +## Integration + +**Called by:** +- **subagent-driven-development** — After all tasks complete +- **executing-plans** — After all batches complete + +**Pairs with:** +- **using-git-worktrees** — Cleans up worktree created by that skill diff --git a/docs/superpowers/skills/requesting-code-review/SKILL.md b/docs/superpowers/skills/requesting-code-review/SKILL.md new file mode 100644 index 0000000..8bd4bd5 --- /dev/null +++ b/docs/superpowers/skills/requesting-code-review/SKILL.md @@ -0,0 +1,112 @@ +--- +name: requesting-code-review +description: Use when completing tasks, implementing major features, or before merging to verify work meets requirements +--- + +# Requesting Code Review + +Dispatch a code review subagent to catch issues before they cascade. The reviewer gets precisely crafted context for evaluation — never your session's history. + +**Core principle:** Review early, review often. + +## When to Request Review + +**Mandatory:** +- After each task in subagent-driven development +- After completing major feature +- Before merge to main + +**Optional but valuable:** +- When stuck (fresh perspective) +- Before refactoring (baseline check) +- After fixing complex bug + +## How to Request + +**1. Get git SHAs:** +```bash +BASE_SHA=$(git rev-parse HEAD~1) +HEAD_SHA=$(git rev-parse HEAD) +``` + +**2. Dispatch code-reviewer subagent** with these inputs: +- **WHAT_WAS_IMPLEMENTED** — What you just built +- **PLAN_OR_REQUIREMENTS** — What it should do (link to plan or task text) +- **BASE_SHA** — Starting commit +- **HEAD_SHA** — Ending commit +- **DESCRIPTION** — Brief summary + +**3. Act on feedback:** +- Fix Critical issues immediately +- Fix Important issues before proceeding +- Note Minor issues for later +- Push back if reviewer is wrong (with reasoning) + +## Code Reviewer Subagent Prompt Template + +``` +Review the code changes for quality and correctness. + +## What was implemented +{WHAT_WAS_IMPLEMENTED} + +## Requirements / Plan +{PLAN_OR_REQUIREMENTS} + +## Git diff +BASE_SHA: {BASE_SHA} +HEAD_SHA: {HEAD_SHA} + +Review for: +1. Does the code meet the stated requirements? +2. Are there bugs, edge cases, or logic errors? +3. Is error handling adequate? +4. Is the code clear and maintainable? +5. Are there performance concerns? +6. Does it follow project conventions (React 18, Tailwind, Vitest)? + +Report: +- Strengths +- Issues by severity (Critical / Important / Minor) +- Overall assessment: Ready to proceed, or needs fixes +``` + +## Example + +``` +[Just completed Task 2: Add verification function] +You: Let me request code review before proceeding. + +BASE_SHA=$(git log --oneline | grep "Task 1" | head -1 | awk '{print $1}') +HEAD_SHA=$(git rev-parse HEAD) + +[Dispatch code-reviewer subagent] + +[Subagent returns]: +Strengths: Clean architecture, real tests +Issues: Important: Missing progress indicators + Minor: Magic number (100) for reporting interval +Assessment: Ready to proceed + +You: [Fix progress indicators] +[Continue to Task 3] +``` + +## Red Flags + +**Never:** +- Skip review because "it's simple" +- Ignore Critical issues +- Proceed with unfixed Important issues +- Argue with valid technical feedback + +**If reviewer wrong:** +- Push back with technical reasoning +- Show code/tests that prove it works +- Request clarification + +## Integration + +**Used by:** +- **subagent-driven-development** — After each task +- **executing-plans** — After each batch diff --git a/docs/superpowers/skills/subagent-driven-development/SKILL.md b/docs/superpowers/skills/subagent-driven-development/SKILL.md new file mode 100644 index 0000000..fbb6925 --- /dev/null +++ b/docs/superpowers/skills/subagent-driven-development/SKILL.md @@ -0,0 +1,138 @@ +--- +name: subagent-driven-development +description: Use when executing implementation plans with independent tasks in the current session +--- + +# Subagent-Driven Development + +Execute plan by dispatching fresh subagent per task, with two-stage review after each: spec compliance review first, then code quality review. + +**Why subagents:** You delegate tasks to specialized agents with isolated context. They should never inherit your session's context or history — you construct exactly what they need. This preserves your own context for coordination work. + +**Core principle:** Fresh subagent per task + two-stage review (spec then quality) = high quality, fast iteration + +## When to Use + +Use when you have an implementation plan (from writing-plans skill) with multiple independent tasks. + +## The Process + +1. **Read plan file** — `docs/superpowers/plans/[name].md` +2. **Extract all tasks** with full text and context +3. **Create TodoWrite** with all tasks +4. **For each task:** + a. Dispatch implementer subagent with full task text + context + b. If implementer asks questions → answer, provide context, re-dispatch + c. Implementer implements, tests, commits, self-reviews + d. Dispatch spec reviewer subagent → confirms code matches spec + e. If spec issues → implementer fixes, re-review + f. Dispatch code quality reviewer subagent → reviews quality + g. If quality issues → implementer fixes, re-review + h. Mark task complete in TodoWrite +5. **After all tasks:** Dispatch final code reviewer for entire implementation +6. **Invoke finishing-a-development-branch** to complete + +## Implementer Subagent Prompt Template + +When dispatching an implementer subagent, provide: + +``` +You are implementing Task [N] from the implementation plan. + +## Task +[Full task text from plan] + +## Context +- Project: Construction Delivery Control (React + Supabase) +- Stack: React 18, React Router 7, Tailwind CSS, Framer Motion, Vitest +- Test convention: TDD — write failing test first, minimal code to pass +- File conventions: Components by domain in src/components/, services in src/services/ + +## Files to modify +[List exact file paths] + +## Requirements +- Follow TDD: write failing test first, then minimal code +- Commit after each passing test +- Self-review your work before reporting complete +- Do NOT modify files outside this task's scope + +Report status when done: DONE, DONE_WITH_CONCERNS, NEEDS_CONTEXT, or BLOCKED. +``` + +## Spec Reviewer Subagent Prompt Template + +``` +Review whether the implementation matches the spec for Task [N]. + +## Task Spec +[Full task text from plan] + +## What was implemented +[Summary from implementer] + +## Files changed +[git diff or file list] + +Check: +1. Does the code implement all requirements from the task? +2. Does the code add anything NOT requested? +3. Are there any missing edge cases or error handling from the spec? + +Report: ✅ Spec compliant, or ❌ with specific issues. +``` + +## Code Quality Reviewer Subagent Prompt Template + +``` +Review the code quality for Task [N]. + +## What was implemented +[Summary] + +## Files changed +[git diff between BASE_SHA and HEAD_SHA] + +Review for: +1. Code clarity and readability +2. Proper error handling +3. Consistent naming and style (follow existing project conventions) +4. Test quality and coverage +5. No magic numbers or hardcoded values +6. Proper separation of concerns + +Report strengths, issues (with severity), and approval status. +``` + +## Handling Implementer Status + +- **DONE:** Proceed to spec compliance review +- **DONE_WITH_CONCERNS:** Read concerns before proceeding. Address if about correctness/scope. +- **NEEDS_CONTEXT:** Provide missing context and re-dispatch +- **BLOCKED:** Assess blocker — context issue? Provide context. Reasoning issue? Use more capable model. Plan issue? Escalate to human + +## Model Selection + +- **Mechanical tasks** (1-2 files, clear spec) → use fast/cheap model +- **Integration tasks** (multi-file, coordination) → use standard model +- **Architecture/review** → use most capable available model + +## Red Flags + +**Never:** +- Start implementation on main/master branch +- Skip reviews (spec compliance OR code quality) +- Proceed with unfixed issues +- Dispatch multiple implementation subagents in parallel (conflicts) +- Make subagent read plan file (provide full text instead) +- Ignore subagent questions +- Accept "close enough" on spec compliance +- Start code quality review before spec compliance is ✅ +- Move to next task while either review has open issues + +## Integration + +**Required workflow skills:** +- **superpowers:using-git-worktrees** — REQUIRED: Set up isolated workspace before starting +- **superpowers:writing-plans** — Creates the plan this skill executes +- **superpowers:finishing-a-development-branch** — Complete development after all tasks diff --git a/docs/superpowers/skills/systematic-debugging/SKILL.md b/docs/superpowers/skills/systematic-debugging/SKILL.md new file mode 100644 index 0000000..4f97c3b --- /dev/null +++ b/docs/superpowers/skills/systematic-debugging/SKILL.md @@ -0,0 +1,119 @@ +--- +name: systematic-debugging +description: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes +--- + +# Systematic Debugging + +## Overview +Random fixes waste time and create new bugs. Quick patches mask underlying issues. + +**Core principle:** ALWAYS find root cause before attempting fixes. Symptom fixes are failure. + +## The Iron Law +``` +NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST +``` +If you haven't completed Phase 1, you cannot propose fixes. + +## When to Use +Use for ANY technical issue: +- Test failures +- Bugs in production +- Unexpected behavior +- Performance problems +- Build failures +- Integration issues + +**Use this ESPECIALLY when:** +- Under time pressure (emergencies make guessing tempting) +- "Just one quick fix" seems obvious +- You've already tried multiple fixes +- Previous fix didn't work + +## The Four Phases + +You MUST complete each phase before proceeding to the next. + +### Phase 1: Root Cause Investigation +**BEFORE attempting ANY fix:** + +1. **Read Error Messages Carefully** + - Don't skip past errors or warnings + - Read stack traces completely + - Note line numbers, file paths, error codes + +2. **Reproduce Consistently** + - Can you trigger it reliably? + - What are the exact steps? + - If not reproducible → gather more data, don't guess + +3. **Check Recent Changes** + - What changed that could cause this? + - `git diff`, recent commits + - New dependencies, config changes + +4. **Gather Evidence in Multi-Component Systems** + When system has multiple components (frontend → Supabase → Edge Functions): + - Log what data enters each component + - Log what data exits each component + - Verify environment/config propagation + - Check state at each layer + +5. **Trace Data Flow** + - Where does bad value originate? + - What called this with bad value? + - Keep tracing up until you find the source + - Fix at source, not at symptom + +### Phase 2: Pattern Analysis +**Find the pattern before fixing:** + +1. **Find Working Examples** — Locate similar working code in same codebase +2. **Compare Against References** — Read reference implementation COMPLETELY +3. **Identify Differences** — List every difference, however small +4. **Understand Dependencies** — What other components, config, environment? + +### Phase 3: Hypothesis and Testing +**Scientific method:** + +1. **Form Single Hypothesis** — "I think X is the root cause because Y" +2. **Test Minimally** — SMALLEST possible change, one variable at a time +3. **Verify Before Continuing** — Did it work? Yes → Phase 4. No → new hypothesis +4. **When You Don't Know** — Say it. Don't pretend. Ask for help. + +### Phase 4: Implementation +**Fix the root cause, not the symptom:** + +1. **Create Failing Test Case** — Simplest possible reproduction +2. **Implement Single Fix** — ONE change at a time, no "while I'm here" +3. **Verify Fix** — Test passes? No other tests broken? +4. **If 3+ Fixes Failed: Question Architecture** — Discuss with human partner + +## Red Flags — STOP and Follow Process + +If you catch yourself thinking: +- "Quick fix for now, investigate later" +- "Just try changing X and see if it works" +- "It's probably X, let me fix that" +- "One more fix attempt" (when already tried 2+) + +**ALL of these mean: STOP. Return to Phase 1.** + +## This Project's Debugging Tips + +- **Vite dev server**: `npm run dev` — check browser console and terminal output +- **Vitest**: `npm run test` — full test suite output +- **ESLint**: `npm run lint` — catches syntax and style issues +- **Supabase**: Check `src/supabaseClient.js` config and `.env` variables +- **React errors**: Check browser DevTools console for component errors +- **Service layer**: Pure functions in `src/services/` are easiest to debug in isolation + +## Quick Reference + +| Phase | Key Activities | Success Criteria | +|-------|---------------|------------------| +| **1. Root Cause** | Read errors, reproduce, check changes | Understand WHAT and WHY | +| **2. Pattern** | Find working examples, compare | Identify differences | +| **3. Hypothesis** | Form theory, test minimally | Confirmed or new hypothesis | +| **4. Implementation** | Create test, fix, verify | Bug resolved, tests pass | diff --git a/docs/superpowers/skills/test-driven-development/SKILL.md b/docs/superpowers/skills/test-driven-development/SKILL.md new file mode 100644 index 0000000..f3a718a --- /dev/null +++ b/docs/superpowers/skills/test-driven-development/SKILL.md @@ -0,0 +1,147 @@ +--- +name: test-driven-development +description: Use when implementing any feature or bugfix, before writing implementation code +--- + +# Test-Driven Development (TDD) + +## Overview +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +## When to Use +**Always:** +- New features +- Bug fixes +- Refactoring +- Behavior changes + +**Exceptions (ask your human partner):** +- Throwaway prototypes +- Generated code +- Configuration files + +## The Iron Law +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` +Write code before the test? Delete it. Start over. + +## Red-Green-Refactor + +### RED — Write Failing Test +Write one minimal test showing what should happen. + +```javascript +test('retries failed operations 3 times', async () => { + let attempts = 0; + const operation = () => { + attempts++; + if (attempts < 3) throw new Error('fail'); + return 'success'; + }; + + const result = await retryOperation(operation); + expect(result).toBe('success'); + expect(attempts).toBe(3); +}); +``` + +**Requirements:** +- One behavior +- Clear name +- Real code (no mocks unless unavoidable) + +### Verify RED — Watch It Fail +**MANDATORY. Never skip.** + +```bash +npm test -- path/to/test.test.js +``` + +Confirm: +- Test fails (not errors) +- Failure message is expected +- Fails because feature missing (not typos) + +**Test passes?** You're testing existing behavior. Fix test. +**Test errors?** Fix error, re-run until it fails correctly. + +### GREEN — Minimal Code +Write simplest code to pass the test. Don't add features, refactor other code, or "improve" beyond the test. + +### Verify GREEN — Watch It Pass +**MANDATORY.** + +```bash +npm test -- path/to/test.test.js +``` + +Confirm: +- Test passes +- Other tests still pass +- Output pristine (no errors, warnings) + +**Test fails?** Fix code, not test. +**Other tests fail?** Fix now. + +### REFACTOR — Clean Up +After green only: +- Remove duplication +- Improve names +- Extract helpers + +Keep tests green. Don't add behavior. + +### Repeat +Next failing test for next feature. + +## This Project's Test Conventions + +- Test files live alongside source: `src/services/orderService.test.js` +- Test runner: **Vitest** (`npm run test`) +- Tests are `.test.js` files, co-located with the module they test +- Existing tested modules: `orderService.js`, `deliveryInvitationApi.js`, `driverDeliveries.js`, `orderViews.js` +- Mock data available in `src/data/mockAppData.js` for test fixtures + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "Too simple to test" | Simple code breaks. Test takes 30 seconds. | +| "I'll test after" | Tests passing immediately prove nothing. | +| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. | +| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. | +| "TDD is dogmatic, I'm being pragmatic" | TDD IS pragmatic: finds bugs before commit, prevents regressions. | + +## Red Flags — STOP and Start Over + +- Code before test +- Test passes immediately +- Test after implementation +- "I already manually tested it" +- "This is different because..." + +**All of these mean: Delete code. Start over with TDD.** + +## Verification Checklist + +Before marking work complete: +- [ ] Every new function/method has a test +- [ ] Watched each test fail before implementing +- [ ] Each test failed for expected reason (feature missing, not typo) +- [ ] Wrote minimal code to pass each test +- [ ] All tests pass +- [ ] Output pristine (no errors, warnings) +- [ ] Tests use real code (mocks only if unavoidable) +- [ ] Edge cases and errors covered + +Can't check all boxes? You skipped TDD. Start over. + +## Final Rule +``` +Production code → test exists and failed first +Otherwise → not TDD +``` +No exceptions without your human partner's permission. diff --git a/docs/superpowers/skills/using-git-worktrees/SKILL.md b/docs/superpowers/skills/using-git-worktrees/SKILL.md new file mode 100644 index 0000000..b9233d2 --- /dev/null +++ b/docs/superpowers/skills/using-git-worktrees/SKILL.md @@ -0,0 +1,111 @@ +--- +name: using-git-worktrees +description: Use when starting feature work that needs isolation from current workspace or before executing implementation plans +--- + +# Using Git Worktrees + +## Overview +Git worktrees create isolated workspaces sharing the same repository, allowing work on multiple branches simultaneously without switching. + +**Core principle:** Systematic directory selection + safety verification = reliable isolation. + +**Announce at start:** "I'm using the using-git-worktrees skill to set up an isolated workspace." + +## Directory Selection Process + +Follow this priority order: + +### 1. Check Existing Directories + +```bash +ls -d .worktrees 2>/dev/null # Preferred (hidden) +ls -d worktrees 2>/dev/null # Alternative +``` + +This project already has a `.worktrees/` directory — use it. + +### 2. Safety Verification + +**MUST verify directory is ignored before creating worktree:** + +```bash +git check-ignore -q .worktrees 2>/dev/null +``` + +**If NOT ignored:** Add to `.gitignore` and commit before proceeding. + +### 3. Create Worktree + +```bash +project=$(basename "$(git rev-parse --show-toplevel)") +git worktree add .worktrees/$BRANCH_NAME -b $BRANCH_NAME +cd .worktrees/$BRANCH_NAME +``` + +### 4. Run Project Setup + +```bash +# This is a Node.js project +if [ -f package.json ]; then + npm install +fi +``` + +### 5. Verify Clean Baseline + +```bash +npm run test +``` + +**If tests fail:** Report failures, ask whether to proceed or investigate. +**If tests pass:** Report ready. + +### 6. Report Location + +``` +Worktree ready at .worktrees/$BRANCH_NAME +Tests passing (X tests, 0 failures) +Ready to implement. +``` + +## Quick Reference + +| Situation | Action | +|-----------|--------| +| `.worktrees/` exists | Use it (verify ignored) | +| `.worktrees/` not ignored | Add to `.gitignore` + commit first | +| Tests fail during baseline | Report failures + ask | +| No package.json | Skip dependency install | + +## Common Mistakes + +### Skipping ignore verification +- **Problem:** Worktree contents get tracked, pollute git status +- **Fix:** Always use `git check-ignore` before creating worktree + +### Proceeding with failing tests +- **Problem:** Can't distinguish new bugs from pre-existing issues +- **Fix:** Report failures, get explicit permission to proceed + +## Red Flags + +**Never:** +- Create worktree without verifying it's ignored +- Skip baseline test verification +- Proceed with failing tests without asking + +**Always:** +- Verify directory is ignored +- Auto-detect and run project setup (`npm install`) +- Verify clean test baseline + +## Integration + +**Called by:** +- **brainstorming** — REQUIRED when design is approved and implementation follows +- **subagent-driven-development** — REQUIRED before executing any tasks +- **executing-plans** — REQUIRED before executing any tasks + +**Pairs with:** +- **finishing-a-development-branch** — REQUIRED for cleanup after work complete diff --git a/docs/superpowers/skills/verification-before-completion/SKILL.md b/docs/superpowers/skills/verification-before-completion/SKILL.md new file mode 100644 index 0000000..4f53152 --- /dev/null +++ b/docs/superpowers/skills/verification-before-completion/SKILL.md @@ -0,0 +1,74 @@ +--- +name: verification-before-completion +description: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs +--- + +# Verification Before Completion + +## Overview +Claiming work is complete without verification is dishonesty, not efficiency. + +**Core principle:** Evidence before claims, always. + +## The Iron Law +``` +NO COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE +``` +If you haven't run the verification command in this message, you cannot claim it passes. + +## The Gate Function + +``` +BEFORE claiming any status or expressing satisfaction: +1. IDENTIFY: What command proves this claim? +2. RUN: Execute the FULL command (fresh, complete) +3. READ: Full output, check exit code, count failures +4. VERIFY: Does output confirm the claim? + - If NO: State actual status with evidence + - If YES: State claim WITH evidence +5. ONLY THEN: Make the claim +``` + +## This Project's Verification Commands + +| Claim | Command | +|-------|---------| +| Tests pass | `npm run test` | +| Lint clean | `npm run lint` | +| Build succeeds | `npm run build` | +| Dev server works | `npm run dev` (check browser + terminal) | + +## Common Failures + +| Claim | Requires | Not Sufficient | +|-------|----------|----------------| +| Tests pass | `npm run test` output: 0 failures | Previous run, "should pass" | +| Lint clean | `npm run lint` output: 0 errors | Partial check | +| Build succeeds | `npm run build`: exit 0 | Lint passing | +| Bug fixed | Test original symptom: passes | Code changed, assumed fixed | + +## Red Flags — STOP + +- Using "should", "probably", "seems to" +- Expressing satisfaction before verification ("Great!", "Perfect!", "Done!") +- About to commit without verification +- Relying on partial verification +- Thinking "just this once" + +## Key Patterns + +**Tests:** +``` +✅ [Run npm test] [See: all pass] "All tests pass" +❌ "Should pass now" / "Looks correct" +``` + +**Build:** +``` +✅ [Run npm run build] [See: exit 0] "Build succeeds" +❌ "Lint passed" (lint ≠ build) +``` + +## The Bottom Line + +**No shortcuts for verification.** Run the command. Read the output. THEN claim the result. diff --git a/docs/superpowers/skills/writing-plans/SKILL.md b/docs/superpowers/skills/writing-plans/SKILL.md new file mode 100644 index 0000000..0bb2d44 --- /dev/null +++ b/docs/superpowers/skills/writing-plans/SKILL.md @@ -0,0 +1,134 @@ +--- +name: writing-plans +description: Use when you have a spec or requirements for a multi-step task, before touching code +--- + +# Writing Plans + +## Overview +Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, how to test it. Give them the whole plan as bite-sized tasks. + +DRY. YAGNI. TDD. Frequent commits. + +Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. + +**Announce at start:** "I'm using the writing-plans skill to create the implementation plan." +**Context:** This should be run in a dedicated worktree (created by brainstorming skill). +**Save plans to:** `docs/superpowers/plans/YYYY-MM-DD--feature-name.md` + +## Scope Check +If the spec covers multiple independent subsystems, it should have been broken into sub-project specs during brainstorming. If it wasn't, suggest breaking this into separate plans — one per subsystem. + +## File Structure +Before defining tasks, map out which files will be created or modified and what each one is responsible for. + +- Design units with clear boundaries and well-defined interfaces. Each file should have one clear responsibility. +- Files that change together should live together. Split by responsibility, not by technical layer. +- In this codebase, follow established patterns: + - Components by domain: `src/components/orders/`, `src/components/dashboard/`, etc. + - Services in `src/services/` with pure functions, tests in same directory as `.test.js` + - Supabase adapters in `src/services/supabase/` + - Context providers in `src/context/` + - Constants in `src/constants/` + - Hooks in `src/hooks/` + +## Bite-Sized Task Granularity +**Each step is one action (2-5 minutes):** +- "Write the failing test" — step +- "Run it to make sure it fails" — step +- "Implement the minimal code to pass" — step +- "Run the tests and make sure they pass" — step +- "Commit" — step + +## Plan Document Header +**Every plan MUST start with this header:** + +```markdown +# [Feature Name] Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** [One sentence describing what this builds] +**Architecture:** [2-3 sentences about approach] +**Tech Stack:** [Key technologies/libraries] + +--- +``` + +## Task Structure + +```markdown +### Task N: [Component Name] + +**Files:** +- Create: `exact/path/to/file.jsx` +- Modify: `exact/path/to/existing.jsx:123-145` +- Test: `exact/path/to/file.test.js` + +- [ ] **Step 1: Write the failing test** +```javascript +test('specific behavior', () => { + // test code +}); +``` + +- [ ] **Step 2: Run test to verify it fails** +Run: `npm test -- path/to/test.test.js` +Expected: FAIL with "function not defined" + +- [ ] **Step 3: Write minimal implementation** +```javascript +// implementation code +``` + +- [ ] **Step 4: Run test to verify it passes** +Run: `npm test -- path/to/test.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** +```bash +git add path/to/file.jsx path/to/file.test.js +git commit -m "feat: add specific feature" +``` +``` + +## No Placeholders +Every step must contain the actual content an engineer needs. These are **plan failures** — never write them: +- "TBD", "TODO", "implement later", "fill in details" +- "Add appropriate error handling" / "add validation" / "handle edge cases" +- "Write tests for the above" (without actual test code) +- "Similar to Task N" (repeat the code) +- Steps that describe what to do without showing how (code blocks required for code steps) +- References to types, functions, or methods not defined in any task + +## Remember +- Exact file paths always +- Complete code in every step — if a step changes code, show the code +- Exact commands with expected output +- DRY, YAGNI, TDD, frequent commits + +## Self-Review +After writing the complete plan, look at the spec with fresh eyes and check the plan against it. + +**1. Spec coverage:** Skim each section/requirement in the spec. Can you point to a task that implements it? List any gaps. +**2. Placeholder scan:** Search your plan for red flags. Fix them. +**3. Type consistency:** Do the types, method signatures, and property names you used in later tasks match what you defined in earlier tasks? Fix any issues inline. + +## Execution Handoff +After saving the plan, offer execution choice: + +**"Plan complete and saved to `docs/superpowers/plans/[name].md`. Two execution options:** + +**1. Subagent-Driven (recommended)** +- I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** +- Execute tasks in this session, batch execution with checkpoints + +Which approach?" + +**If Subagent-Driven chosen:** +- **REQUIRED SUB-SKILL:** Use superpowers:subagent-driven-development + +**If Inline Execution chosen:** +- **REQUIRED SUB-SKILL:** Use superpowers:executing-plans diff --git a/docs/superpowers/specs/2026-03-15-mobile-wave-1-design.md b/docs/superpowers/specs/2026-03-15-mobile-wave-1-design.md new file mode 100644 index 0000000..5767bda --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-mobile-wave-1-design.md @@ -0,0 +1,160 @@ +# Mobile Wave 1 Design + +**Date:** 2026-03-15 + +**Goal:** Make the product usable for full day-to-day work on phones across wave 1 scope: app shell, login, and the full orders workspace. + +## Context + +- The product currently uses desktop-first layouts with dense grids, side navigation, wide tables, and multi-column work areas. +- Recent orders work added a richer kanban, role-aware filters, and responsibility coloring, but many of those interactions still assume desktop space. +- Phone usage is now a first-class requirement, not a reduced read-only mode. +- Wave 1 is intentionally limited to: + - app shell and navigation + - login + - orders workspace: registry, kanban, calendar, order detail, order editor, and filtering + +## Decision + +Implement a dual-presentation responsive architecture: + +- keep existing business logic, routing, and data hooks +- preserve desktop-oriented layouts on larger breakpoints +- introduce mobile-specific representations for wave 1 screens where the workflow materially differs on phones + +This avoids rewriting the app while still allowing a truly workable mobile UX. + +## Architecture + +### Responsive shell + +`AppShell` becomes two navigation systems: + +- desktop/tablet sidebar from `xl` +- mobile shell below `xl` with: + - compact top header + - section title and context + - bottom navigation for primary sections + +The mobile shell must keep sign-out and theme switching reachable without consuming permanent sidebar space. + +### Orders workspace as mobile-first operations surface + +The orders area becomes the center of wave 1 mobile work: + +- registry turns into a stacked card list on phones +- kanban keeps column logic but becomes horizontally scrollable with fixed-width columns +- calendar uses a mobile agenda/list view instead of defaulting to a seven-column grid +- order detail opens as a full-screen mobile workspace +- order editing becomes a single-column form with sticky actions + +The data model stays shared; only presentation changes by breakpoint. + +### Modal strategy + +Phone interaction must not rely on floating desktop dialogs. For wave 1: + +- modals used for order detail or order creation should become full-screen overlays on mobile +- filter-heavy interfaces should use bottom sheets or mobile overlays rather than showing all controls inline + +### Status management on mobile + +Phone UX must not depend on drag-and-drop as the only operational path. + +- kanban may still support drag on devices where it works +- status changes must always be available through explicit controls inside order detail +- dense tables or hover-only affordances are out of scope for phone UX + +## UX Behavior + +### Login + +- single-column, narrow max width +- large controls and clear primary CTA +- no wasted side padding on small screens +- OTP step remains inside the same focused mobile card + +### App shell + +- top area shows current section, user role, and quick utility actions +- bottom nav surfaces primary destinations +- content padding is reduced on phones to maximize usable area + +### Orders registry + +- desktop table stays for larger screens +- mobile list cards show: + - order number + - customer + - exact status + - responsibility/department hint + - updated time + - short summary +- tapping a card opens the order workspace + +### Orders filters + +- phones show a compact search bar and a dedicated `Фильтры` entry point +- advanced filters open in a mobile overlay/sheet +- active filters are shown as removable chips under search + +### Orders kanban + +- horizontal scroll is mandatory on phones +- columns have fixed minimum width +- controls stack vertically above the board +- department filter and view-mode switch remain available on mobile +- completed column stays available as a valid target in stage mode + +### Orders calendar + +- mobile default is an agenda/day-grouped list +- desktop calendar grid remains for larger breakpoints +- month navigation stays, but the actual content favors readable daily lists on phones + +### Order detail + +- mobile uses a single-column full-screen view +- sections become stacked and optionally collapsible +- status action area is placed near the top +- notes/history/chat remain accessible without crowding the initial viewport + +### Order editor + +- single-column form +- grouped logical sections +- sticky footer action bar for save/create +- no two-column field layout on phones + +## File Responsibilities + +- `src/layouts/AppShell.jsx` — responsive shell split between desktop sidebar and mobile header/bottom nav +- `src/pages/LoginPage.jsx` and auth components — mobile-focused login spacing and flow +- `src/pages/DashboardPage.jsx` — mobile orchestration across orders views and modal/full-screen behavior +- `src/components/orders/OrdersTable.jsx` — desktop table + mobile card list +- `src/components/orders/OrdersCalendarView.jsx` — desktop month grid + mobile agenda view +- `src/components/orders/OrdersKanbanBoard.jsx` — horizontal mobile board and stacked toolbar +- `src/components/orders/OrderFilters.jsx` — compact mobile filters and active filter chips +- `src/components/orders/OrderDetailPanel.jsx` — mobile single-column detail layout +- `src/components/orders/OrderEditorPanel.jsx` — mobile form and sticky action bar +- `src/components/UI/Modal.jsx` — responsive behavior for full-screen mobile overlays if needed + +## Testing Strategy + +- add focused component tests for new mobile-specific render paths where practical +- verify responsive branch rendering with server-side markup tests for compact layouts +- run existing order-related tests to catch workflow regressions +- manually verify at narrow viewport widths: + - login + - mobile shell navigation + - registry card list + - kanban horizontal scroll + - calendar agenda view + - order detail usability + - order editor sticky action area + +## Out Of Scope + +- wave 2 mobile work for driver/logistics/production dedicated screens +- wave 3 mobile work for admin and reference-heavy screens +- offline-first mobile behavior changes beyond current app capabilities diff --git a/docs/superpowers/specs/2026-03-15-role-aware-orders-kanban-design.md b/docs/superpowers/specs/2026-03-15-role-aware-orders-kanban-design.md new file mode 100644 index 0000000..74280fc --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-role-aware-orders-kanban-design.md @@ -0,0 +1,179 @@ +# Role-Aware Orders Kanban Design + +**Date:** 2026-03-15 + +**Goal:** Add a shared orders kanban that can switch between stage-based and status-based views, shows responsibility by role, highlights stalled orders, and lets each employee work only within their responsibility zone while managers see the full pipeline. + +## Context + +- The current orders workspace already has filters, a registry, a calendar, a basic kanban, and an order detail panel. +- Status ownership already exists in `src/constants/deliveryWorkflow.js` via `ownerRole`. +- Current order visibility is assignment-based for manager, logisticians, and drivers, which conflicts with the requested "see all orders at your current work stage" behavior. +- The current kanban is hard-coded to a few grouped columns and does not support dual display modes, responsibility coloring, or aging/SLA highlighting. +- Status changes are already supported from the detail panel and by drag-and-drop in the kanban, but both paths need to be aligned around the same responsibility-aware rules. + +## Decision + +Implement one kanban engine with two display modes: + +- `by_stage` for managers and employees who want a compact pipeline view +- `by_status` for detailed operational work + +The kanban will derive its structure entirely from status metadata. Each status will describe: + +- which role owns it +- which stage it belongs to +- how long it can stay there before warning/critical aging states apply + +Managers and admins will see the full pipeline. Other roles will see only orders whose current status belongs to their responsibility zone. Status changes will remain available from both kanban drag-and-drop and the order detail panel, but only within transitions allowed for the current user role. + +## Architecture + +### Shared workflow metadata + +Extend delivery workflow metadata so every status becomes a full configuration record: + +- `ownerRole` +- `stageKey` +- `stageLabel` +- `tone` +- `warningAfterHours` +- `criticalAfterHours` + +This keeps filters, visibility, kanban columns, card badges, and notifications driven from one source of truth. + +### Responsibility-aware visibility + +Order visibility will be split into two concepts: + +- assignment fields such as `managerId`, `logisticianIds`, `assignedDriverId` +- current responsibility, derived from the current status metadata + +Managers and admins see all visible orders. Other roles see any order whose current `ownerRole` matches their role, even if a different specific employee is assigned. Assignment remains searchable and filterable, but it no longer limits the kanban itself. + +### Two kanban modes + +The kanban workspace will expose a view switch: + +- `По этапам` +- `По статусам` + +In `by_stage`, columns represent large business steps such as manager, production, logistics, delivery, and completed. Cards still display the exact current status. + +In `by_status`, each working status gets its own column. Completed statuses can remain hidden by default with an explicit toggle. + +Both modes reuse the same card component, filter state, drag-and-drop logic, and permissions. + +### Responsibility colors + +Responsibility color communicates who currently owns the order: + +- manager +- production lead +- logistician +- driver +- completed/cancelled neutral state + +To avoid collision with aging signals, responsibility will use a stable visual element on the card such as a top border, side rail, or role badge. Aging state will use separate warning/critical styling on the card frame and badge text. + +### Aging and SLA signals + +Every status receives two aging thresholds: + +- warning +- critical + +The app computes time spent in the current status using the latest history entry that moved the order into that status. Each order then gets a derived aging state: + +- `normal` +- `warning` +- `critical` + +Cards show the elapsed time in the current status and visually escalate when thresholds are crossed. Kanban columns can also show counts of warning/critical orders. + +### Notifications + +In the current frontend-only demo architecture, notifications will be in-app derived alerts. The same data model should later support background processing in Supabase or another backend. + +Phase 1 notification behavior: + +- emit a notification when an order first enters warning +- emit a notification when an order first enters critical +- show warning/critical counts in the dashboard and kanban workspace +- allow filtering to only warning/critical orders + +For the demo, these alerts can be recalculated from client state during render/hook updates instead of requiring background timers. + +## UX Behavior + +### Search and filters + +The order filters should work consistently for the registry, calendar, and both kanban modes: + +- search by order number +- search by customer first/last name +- search by customer phone +- filter by exact status +- filter by stage +- filter by responsibility role +- filter by manager +- filter by logistician +- filter by messenger/channel +- filter by aging state + +Search should include order number, customer name, customer phone, status, tags, comments, and item names. + +### Kanban cards + +Each card should surface at a glance: + +- order number +- customer name +- short item summary +- exact current status +- responsibility role color +- aging badge such as `24ч в статусе` + +Critical orders should be visually distinct enough that one scan of the board reveals stuck work. + +### Status changes + +Users can change status in two places: + +- drag a card to another column +- choose a new status inside the order detail panel + +Both paths must use the same permission logic: + +- available transitions are derived from `ORDER_STATUS_TRANSITIONS` +- transitions are then filtered by the current role's allowed targets + +When dragging in stage mode, the target stage may contain several statuses. The drop handler must resolve the best allowed target status for that role in that stage, or reject the drop if no valid target exists. + +## File Responsibilities + +- `src/constants/deliveryWorkflow.js` — enrich status metadata with stage and SLA config +- `src/constants/orderStatuses.js` — continue re-exporting workflow-driven statuses +- `src/services/orderService.js` — expand search blob and responsibility-aware filtering helpers if needed +- `src/services/orderViews.js` — build stage/status kanban columns, aging state, and stalled counts +- `src/services/orderViews.test.js` — verify kanban modes, visibility helpers, and aging calculations +- `src/hooks/useOrders.js` — switch visible order rules from assignment-based to responsibility-based and expose derived alert data +- `src/components/orders/OrderFilters.jsx` — add stage/responsibility/aging filters and richer search copy +- `src/components/orders/OrderDetailPanel.jsx` — keep role-limited status changes aligned with kanban behavior +- `src/pages/DashboardPage.jsx` — wire the new kanban controls, counts, and notification summaries +- optional new helper/component files under `src/components/orders/` if the kanban card or toolbar becomes too large + +## Testing Strategy + +- Add unit tests for status metadata derived helpers and kanban column building in both modes. +- Add unit tests for aging state calculation at normal, warning, and critical thresholds. +- Add unit tests for responsibility-based visibility and filters. +- Verify that the same allowed transition set is used by both kanban drag-and-drop and detail-panel status editing. +- Run the existing order service and order views test suites to catch regressions in registry/calendar behavior. + +## Out Of Scope + +- Real background schedulers or push notifications +- New backend tables for SLA tracking +- Per-user notification preferences +- Reworking unrelated driver-only planning screens diff --git a/docs/superpowers/specs/2026-04-13-1c-delivery-ui-design.md b/docs/superpowers/specs/2026-04-13-1c-delivery-ui-design.md new file mode 100644 index 0000000..77fc293 --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-1c-delivery-ui-design.md @@ -0,0 +1,76 @@ +# 1C Delivery UI Design Spec + +## Overview + +The frontend is a delivery workspace centered on logisticians, drivers, admins, and the public client delivery page. Orders are imported from 1C; the web app does not create orders. Delivery automation starts only when the full client order set is ready. + +## Key Principles + +1. **1C is the source of truth for order creation and production progress.** The web app never creates new orders; it receives them via import. +2. **Supabase is the source of truth for delivery workflow state.** Invitations, slots, delivery-set groupings, and operator actions all live in Supabase. +3. **Delivery sets, not individual orders, are the primary unit.** A client delivery set groups all orders for the same client and becomes actionable when every order in the set is accepted by QC (`source_accept_at` present, `source_ship_at` absent). +4. **The legacy SMS field is informational only.** It must not start the new delivery scenario. +5. **The client is not an authenticated user.** The client uses a public invitation link. + +## Screen Flow + +### Operator Login + +- Email + OTP login via Supabase Auth. +- Unknown email shows: "Email не найден в системе. Обратитесь к администратору." +- After sending OTP, the UI tells the user to check inbox and Spam. +- Roles: `logistician`, `driver`, `admin` (and legacy `manager`, `production_lead`). + +### Logistician Workspace + +The logistician lands on the logistics section of the dashboard. The primary widget is the **LogisticsReadinessBoard** showing delivery sets grouped into buckets: + +| Bucket | Russian Label | Meaning | +|--------|--------------|---------| +| `approaching` | На подходе | Some orders in the set have not yet been accepted by QC | +| `ready_to_launch` | Готово к запуску | All orders accepted, delivery can be started | +| `awaiting_client` | Ожидает клиента | Invitation sent, waiting for client choice | +| `manual_work` | Нужна ручная работа | Transferred to logistician, paid storage, or problem | +| `agreed` | Согласовано | Client confirmed the delivery slot | +| `completed` | Завершено | All orders in the set delivered | + +Clicking a set opens the **DeliverySetDetailPanel** showing: + +- Set name, city, order count, linked bill texts +- Per-order source fields: 1C order number, production steps (saw, glue, H-glue, curve, accept, ship) +- Slot state if available +- Manual action placeholders (future: start invitation, assign driver) + +### Driver Workspace + +The driver sees assigned deliveries with: + +- Address, client name, phone, city +- Delivery time slot (date + half-day) +- Delivery set reference (1C number, set name) +- Quick status transitions: Загружен → В пути → Доставлен / Проблема доставки + +### Client Delivery Page + +Public page at `/delivery/:token`. States: + +- **awaiting_choice / opened / reminder_sent**: Shows DeliverySlotsPicker with date + half-day slots, then DeliveryChoiceFlow buttons. +- **transferred_to_logistics**: "С вами свяжется логист" +- **paid_storage**: "Платное хранение" +- **agreed**: "Доставка уже согласована" +- **delivered**: "Заказ уже доставлен" + +## Data Model Key Fields + +### `public.orders` source fields (imported from 1C) + +- `source_order_number`, `source_order_date`, `source_customer_name`, `source_customer_phone`, `source_customer_email`, `source_customer_city`, `source_total_sum`, `source_paid_at`, `source_gateway`, `source_associated_bills_text`, `source_production_at`, `source_saw_at`, `source_glue_at`, `source_h_glue_at`, `source_curve_at`, `source_accept_at`, `source_ship_at`, `source_payload jsonb` + +### `public.orders` delivery-set fields (computed/derived) + +- `delivery_set_key`, `delivery_set_name`, `delivery_set_status`, `delivery_set_ready_at`, `delivery_ready_reason`, `source_sms_legacy_at` + +### Readiness Rule + +A single imported order is ready when `source_accept_at IS NOT NULL AND source_ship_at IS NULL`. +A client delivery set is ready only when all linked imported orders are ready. \ No newline at end of file diff --git a/src/components/admin/UserDirectoryPanel.jsx b/src/components/admin/UserDirectoryPanel.jsx index 6c2484f..0a3ee24 100644 --- a/src/components/admin/UserDirectoryPanel.jsx +++ b/src/components/admin/UserDirectoryPanel.jsx @@ -5,7 +5,9 @@ import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; -export const UserDirectoryPanel = ({ currentUser }) => { +const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); + +export const UserDirectoryPanel = ({ currentUser, users }) => { if (currentUser.role !== "admin") { return ( <Panel className="p-5"> @@ -28,7 +30,7 @@ export const UserDirectoryPanel = ({ currentUser }) => { </div> <div className="space-y-3"> - {demoUsers.map((user) => ( + {getUsers(users).map((user) => ( <div key={user.id} className="flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4" @@ -36,7 +38,7 @@ export const UserDirectoryPanel = ({ currentUser }) => { <div> <div className="font-medium">{user.name}</div> <div className="text-sm text-[var(--color-text-muted)]">{user.email}</div> - <div className="text-sm text-[var(--color-text-muted)]">{user.phone}</div> + <div className="text-sm text-[var(--color-text-muted)]">{user.phone || "Не указано"}</div> </div> <div className="flex flex-wrap items-center gap-3"> <Badge tone="accent">{ROLE_LABELS[user.role]}</Badge> @@ -45,9 +47,9 @@ export const UserDirectoryPanel = ({ currentUser }) => { </span> </div> <div className="w-full text-sm text-[var(--color-text-muted)]"> - Каналы: Телеграм {user.botBindings.telegram || "не привязан"} · ВКонтакте{" "} - {user.botBindings.vk || "не привязан"} · Макс{" "} - {user.botBindings.messengerMax || "не привязан"} + Каналы: Телеграм {user.botBindings?.telegram || "не привязан"} · ВКонтакте{" "} + {user.botBindings?.vk || "не привязан"} · Макс{" "} + {user.botBindings?.messengerMax || "не привязан"} </div> </div> ))} diff --git a/src/components/auth/OtpLoginForm.jsx b/src/components/auth/OtpLoginForm.jsx index bbb6767..57e2f52 100644 --- a/src/components/auth/OtpLoginForm.jsx +++ b/src/components/auth/OtpLoginForm.jsx @@ -91,6 +91,12 @@ export const OtpLoginForm = ({ </div> )} + {isOtpSent && ( + <div className="rounded-[22px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]"> + Код отправлен на <strong>{email}</strong>. Проверьте входящие и папку Спам. + </div> + )} + {error ? <p className="text-sm text-[var(--color-danger)]">{error}</p> : null} {!isOtpSent ? ( diff --git a/src/components/auth/OtpLoginForm.test.jsx b/src/components/auth/OtpLoginForm.test.jsx index 8ae348b..6e6647b 100644 --- a/src/components/auth/OtpLoginForm.test.jsx +++ b/src/components/auth/OtpLoginForm.test.jsx @@ -23,7 +23,6 @@ describe("OtpLoginForm", () => { const markup = renderToStaticMarkup(<OtpLoginForm {...baseProps} />).toLowerCase(); expect(markup).toContain("введите email"); - expect(markup).toContain("код придет на почту"); expect(markup).toContain("доступ определяется учетной записью"); expect(markup).not.toContain("роль для демо-режима"); }); @@ -36,4 +35,21 @@ describe("OtpLoginForm", () => { expect(markup).toContain("демо-режим активен"); expect(markup).toContain("роль для демо-режима"); }); + + it("tells operators to check inbox and spam after OTP is sent", () => { + const markup = renderToStaticMarkup( + <OtpLoginForm {...baseProps} isOtpSent={true} />, + ).toLowerCase(); + + expect(markup).toContain("входящие"); + expect(markup).toContain("спам"); + }); + + it("shows unknown-email admin-help message when error matches", () => { + const markup = renderToStaticMarkup( + <OtpLoginForm {...baseProps} error="Email не найден в системе. Обратитесь к администратору." />, + ).toLowerCase(); + + expect(markup).toContain("обратитесь к администратору"); + }); }); diff --git a/src/components/client/DeliveryChoiceFlow.jsx b/src/components/client/DeliveryChoiceFlow.jsx new file mode 100644 index 0000000..f1b97c4 --- /dev/null +++ b/src/components/client/DeliveryChoiceFlow.jsx @@ -0,0 +1,64 @@ +import React from "react"; +import { Badge } from "../UI/Badge"; +import { Button } from "../UI/Button"; +import { Panel } from "../UI/Panel"; +import { DeliveryStateNotice } from "./DeliveryStateNotice"; + +const ACTIVE_STATES = new Set(["awaiting_choice", "opened", "reminder_sent"]); + +const STATE_LABELS = { + awaiting_choice: "Ожидает ответа клиента", + opened: "Страница открыта", + reminder_sent: "Напоминание отправлено", + transferred_to_logistics: "Передан логисту", + paid_storage: "Платное хранение", + delivered: "Доставлен", + agreed: "Доставка согласована", +}; + +const DEFAULT_SLOTS = ["Первая половина дня", "Вторая половина дня"]; + +export const DeliveryChoiceFlow = ({ + invitation = {}, + onConfirmChoice = () => {}, + onRequestNewLink = () => {}, +}) => { + const state = invitation.state || "awaiting_choice"; + const isActive = ACTIVE_STATES.has(state); + const slots = invitation.availableSlots?.length ? invitation.availableSlots : DEFAULT_SLOTS; + const orderNumber = invitation.orderNumber || "—"; + const customerName = invitation.customerName || "Клиент"; + + if (!isActive) { + return <DeliveryStateNotice state={state} />; + } + + return ( + <Panel className="space-y-5 p-5 sm:p-6"> + <div className="space-y-2"> + <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Клиентская ссылка</p> + <div className="flex flex-wrap items-center gap-2"> + <h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Выберите время доставки</h1> + <Badge tone="warning">{STATE_LABELS[state]}</Badge> + </div> + <p className="text-sm leading-6 text-[var(--color-text-muted)]"> + Заказ {orderNumber} для {customerName}. Выберите подходящую половину дня и подтвердите выбор. + </p> + </div> + + <div className="grid gap-3 sm:grid-cols-2"> + {slots.map((slot) => ( + <Button key={slot} className="w-full" onClick={() => onConfirmChoice(slot)}> + {slot} + </Button> + ))} + </div> + + <div className="flex flex-col gap-3 sm:flex-row"> + <Button variant="secondary" className="w-full sm:w-auto" onClick={onRequestNewLink}> + Запросить новую ссылку + </Button> + </div> + </Panel> + ); +}; diff --git a/src/components/client/DeliveryChoiceFlow.test.jsx b/src/components/client/DeliveryChoiceFlow.test.jsx new file mode 100644 index 0000000..f485d60 --- /dev/null +++ b/src/components/client/DeliveryChoiceFlow.test.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { DeliveryChoiceFlow } from "./DeliveryChoiceFlow"; + +describe("DeliveryChoiceFlow", () => { + it("renders the active delivery choice with half-day actions", () => { + const markup = renderToStaticMarkup( + <DeliveryChoiceFlow + invitation={{ + state: "awaiting_choice", + orderNumber: "CD-240031", + customerName: "Мария Волкова", + availableSlots: ["Первая половина дня", "Вторая половина дня"], + }} + onConfirmChoice={() => {}} + onRequestNewLink={() => {}} + />, + ); + + expect(markup).toContain("Выберите время доставки"); + expect(markup).toContain("Первая половина дня"); + expect(markup).toContain("Вторая половина дня"); + expect(markup).toContain("Ожидает ответа клиента"); + }); + + it("renders a logistics notice when the order is transferred", () => { + const markup = renderToStaticMarkup( + <DeliveryChoiceFlow + invitation={{ + state: "transferred_to_logistics", + orderNumber: "CD-240031", + customerName: "Мария Волкова", + }} + onConfirmChoice={() => {}} + onRequestNewLink={() => {}} + />, + ); + + expect(markup).toContain("С вами свяжется логист"); + expect(markup).not.toContain("Выберите время доставки"); + }); + + it("renders a paid storage notice when delivery is not coordinated", () => { + const markup = renderToStaticMarkup( + <DeliveryChoiceFlow + invitation={{ + state: "paid_storage", + orderNumber: "CD-240031", + customerName: "Мария Волкова", + }} + onConfirmChoice={() => {}} + onRequestNewLink={() => {}} + />, + ); + + expect(markup).toContain("Платное хранение"); + expect(markup).toContain("Заказ переведен на платное хранение"); + }); + + it("renders awaiting choice state with slot info", () => { + const markup = renderToStaticMarkup( + <DeliveryChoiceFlow + invitation={{ + state: "opened", + orderNumber: "CD-240032", + customerName: "Александр Савин", + availableSlots: ["15 апреля, первая половина дня"], + }} + onConfirmChoice={() => {}} + onRequestNewLink={() => {}} + />, + ); + + expect(markup).toContain("CD-240032"); + expect(markup).toContain("Александр Савин"); + }); +}); diff --git a/src/components/client/DeliverySlotsPicker.jsx b/src/components/client/DeliverySlotsPicker.jsx new file mode 100644 index 0000000..027053f --- /dev/null +++ b/src/components/client/DeliverySlotsPicker.jsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Button } from "../UI/Button"; +import { Panel } from "../UI/Panel"; + +const formatSlotDate = (dateStr) => { + const date = new Date(`${dateStr}T12:00:00`); + return date.toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + weekday: "short", + }); +}; + +const groupSlotsByDate = (slots) => { + const groups = new Map(); + + for (const slot of slots) { + if (!groups.has(slot.date)) { + groups.set(slot.date, []); + } + + groups.get(slot.date).push(slot); + } + + return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)); +}; + +export const DeliverySlotsPicker = ({ slots, onSelectSlot, selectedSlotId }) => { + if (!slots || !slots.length) { + return ( + <Panel className="p-5 sm:p-6"> + <p className="text-sm text-[var(--color-text-muted)]">Нет доступных слотов для выбора. Логист назначит слот позже.</p> + </Panel> + ); + } + + const grouped = groupSlotsByDate(slots); + + return ( + <div className="space-y-4"> + <Panel className="p-5 sm:p-6"> + <h3 className="text-lg font-semibold">Выберите дату и время доставки</h3> + <p className="mt-1 text-sm text-[var(--color-text-muted)]"> + Нажмите на подходящий слот, чтобы подтвердить выбор. + </p> + </Panel> + + {grouped.map(([date, dateSlots]) => ( + <Panel key={date} className="space-y-3 p-5 sm:p-6"> + <h4 className="font-medium capitalize">{formatSlotDate(date)}</h4> + <div className="grid gap-3 sm:grid-cols-2"> + {dateSlots.map((slot) => { + const isSelected = selectedSlotId === slot.id; + + return ( + <Button + key={slot.id} + variant={isSelected ? "primary" : "secondary"} + onClick={() => onSelectSlot(slot)} + > + {slot.time} + {isSelected ? " \u2014 Выбрано" : ""} + </Button> + ); + })} + </div> + </Panel> + ))} + </div> + ); +}; \ No newline at end of file diff --git a/src/components/client/DeliverySlotsPicker.test.jsx b/src/components/client/DeliverySlotsPicker.test.jsx new file mode 100644 index 0000000..8979f77 --- /dev/null +++ b/src/components/client/DeliverySlotsPicker.test.jsx @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { DeliverySlotsPicker } from "./DeliverySlotsPicker"; + +const mockSlots = [ + { date: "2026-04-14", time: "Первая половина дня", id: "slot-1" }, + { date: "2026-04-14", time: "Вторая половина дня", id: "slot-2" }, + { date: "2026-04-15", time: "Первая половина дня", id: "slot-3" }, + { date: "2026-04-15", time: "Вторая половина дня", id: "slot-4" }, +]; + +describe("DeliverySlotsPicker", () => { + it("renders slots grouped by date with half-day choices", () => { + const markup = renderToStaticMarkup( + <DeliverySlotsPicker + slots={mockSlots} + onSelectSlot={() => {}} + selectedSlotId={null} + />, + ).toLowerCase(); + + expect(markup).toContain("14 апреля"); + expect(markup).toContain("15 апреля"); + expect(markup).toContain("первая половина дня"); + expect(markup).toContain("вторая половина дня"); + }); + + it("marks the selected slot", () => { + const markup = renderToStaticMarkup( + <DeliverySlotsPicker + slots={mockSlots} + onSelectSlot={() => {}} + selectedSlotId="slot-2" + />, + ).toLowerCase(); + + expect(markup).toContain("выбрано"); + }); + + it("renders read-only notice when no slots available", () => { + const markup = renderToStaticMarkup( + <DeliverySlotsPicker slots={[]} onSelectSlot={() => {}} selectedSlotId={null} />, + ).toLowerCase(); + + expect(markup).toContain("нет доступных слотов"); + }); +}); \ No newline at end of file diff --git a/src/components/client/DeliveryStateNotice.jsx b/src/components/client/DeliveryStateNotice.jsx new file mode 100644 index 0000000..77f4c5d --- /dev/null +++ b/src/components/client/DeliveryStateNotice.jsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Panel } from "../UI/Panel"; + +const STATE_COPY = { + awaiting_choice: { + title: "Ожидает вашего выбора", + description: "Логист назначил слоты доставки. Выберите подходящую дату и время.", + }, + transferred_to_logistics: { + title: "С вами свяжется логист", + description: "Автоматическое согласование не завершилось, заказ передан в ручную работу логиста.", + }, + paid_storage: { + title: "Платное хранение", + description: "Заказ переведен на платное хранение после всех уведомлений и ручной обработки.", + }, + delivered: { + title: "Заказ уже доставлен", + description: "По этому заказу доставка уже завершена и ссылка больше не используется.", + }, + agreed: { + title: "Доставка уже согласована", + description: "Выбор времени доставки уже сохранён в системе.", + }, + default: { + title: "Требуется ручная обработка", + description: "Сейчас по этому заказу недоступно самостоятельное согласование доставки.", + }, +}; + +export const DeliveryStateNotice = ({ state = "default" }) => { + const copy = STATE_COPY[state] || STATE_COPY.default; + + return ( + <Panel className="space-y-3 p-5 sm:p-6"> + <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Статус ссылки</p> + <h2 className="text-2xl font-semibold leading-tight">{copy.title}</h2> + <p className="text-sm leading-6 text-[var(--color-text-muted)]">{copy.description}</p> + </Panel> + ); +}; \ No newline at end of file diff --git a/src/components/dashboard/RoleWorkspacePanel.jsx b/src/components/dashboard/RoleWorkspacePanel.jsx index ca43147..a3f3d2c 100644 --- a/src/components/dashboard/RoleWorkspacePanel.jsx +++ b/src/components/dashboard/RoleWorkspacePanel.jsx @@ -1,11 +1,12 @@ import React from "react"; import { ROLE_LABELS } from "../../constants/roles"; +import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; const ROLE_MODULES = { manager: [ - "Создание и подтверждение заказов", + "Просмотр импортированных заказов", "Поиск по клиенту, заказу и статусу", "Комментарии и эскалации", ], @@ -15,24 +16,27 @@ const ROLE_MODULES = { "Контроль готовности к отгрузке", ], logistician: [ - "Готовые заказы и слоты", - "Согласование через боты", - "Переносы, отмены, исключения", + "Наборы доставки и слоты", + "Согласование с клиентом и назначение рейса", + "Разбор проблемных доставок и ручная работа", ], driver: [ - "Мои рейсы на сегодня", - "Подтверждение загрузки и выезда", - "Фиксация доставки и проблем", + "Назначенные доставки и маршрут", + "Загрузка, выезд и завершение рейса", + "Фиксация результата доставки", ], admin: [ - "Управление ролями и пользователями", - "Полный аудит истории и логов", - "Контроль интеграций и ошибок", + "Полный доступ к заказам и доставкам", + "Управление пользователями и ролями", + "Логи, ошибки и история действий", ], }; -export const RoleWorkspacePanel = ({ role }) => { +export const RoleWorkspacePanel = ({ role, deliverySetBuckets }) => { const modules = ROLE_MODULES[role] || []; + const totalSets = deliverySetBuckets + ? Object.values(deliverySetBuckets).reduce((sum, sets) => sum + sets.length, 0) + : null; return ( <Panel className="p-5"> @@ -44,7 +48,12 @@ export const RoleWorkspacePanel = ({ role }) => { коду. </p> </div> - <Badge tone="accent">{ROLE_LABELS[role]}</Badge> + <div className="flex flex-wrap gap-2"> + <Badge tone="accent">{ROLE_LABELS[role]}</Badge> + {totalSets !== null ? ( + <Badge tone="neutral">{totalSets} наборов доставки</Badge> + ) : null} + </div> </div> <div className="mt-4 grid gap-3 md:grid-cols-3"> @@ -57,6 +66,20 @@ export const RoleWorkspacePanel = ({ role }) => { </div> ))} </div> + + {deliverySetBuckets && role === "logistician" ? ( + <div className="mt-4 flex flex-wrap gap-2"> + {Object.entries(DELIVERY_SET_BUCKET_LABELS).map(([key, label]) => { + const count = deliverySetBuckets[key]?.length || 0; + + return ( + <Badge key={key} tone={count > 0 ? "accent" : "neutral"}> + {label}: {count} + </Badge> + ); + })} + </div> + ) : null} </Panel> ); -}; +}; \ No newline at end of file diff --git a/src/components/driver/DriverDeliveryDetail.jsx b/src/components/driver/DriverDeliveryDetail.jsx index ddda259..c8cfde5 100644 --- a/src/components/driver/DriverDeliveryDetail.jsx +++ b/src/components/driver/DriverDeliveryDetail.jsx @@ -10,9 +10,10 @@ import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; import { Panel } from "../UI/Panel"; -const resolveUserName = (userId) => demoUsers.find((user) => user.id === userId)?.name || "Не назначен"; +const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); +const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен"; -export const DriverDeliveryDetail = ({ order, onStatusChange }) => { +export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => { if (!order) { return null; } @@ -22,6 +23,10 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => { role: "driver", }); + const deliverySetKey = order.deliverySetKey; + const deliverySetName = order.deliverySetName; + const sourceOrderNumber = order.sourceOrderNumber; + return ( <div className="space-y-4"> <Panel className="space-y-5 p-6"> @@ -36,7 +41,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => { </p> </div> <div className="flex flex-wrap gap-2"> - <Badge tone="neutral">Точка {order.driverRouteOrder || "—"}</Badge> + <Badge tone="neutral">Точка {order.driverRouteOrder || "\u2014"}</Badge> <Badge tone={getStatusTone(order.status)}>{order.status}</Badge> </div> </div> @@ -66,8 +71,20 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => { </div> <div> <p className="text-xs text-[var(--color-text-muted)]">Логист</p> - <p className="mt-1 font-medium">{resolveUserName(order.logisticianIds[0])}</p> + <p className="mt-1 font-medium">{resolveUserName(users, order.logisticianIds?.[0])}</p> </div> + {sourceOrderNumber ? ( + <div> + <p className="text-xs text-[var(--color-text-muted)]">1С номер</p> + <p className="mt-1 font-medium">{sourceOrderNumber}</p> + </div> + ) : null} + {deliverySetKey ? ( + <div> + <p className="text-xs text-[var(--color-text-muted)]">Набор доставки</p> + <p className="mt-1 font-medium">{deliverySetName || deliverySetKey}</p> + </div> + ) : null} </div> </Panel> @@ -105,7 +122,7 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => { {availableTransitions.map((status) => ( <Button key={status} - variant={status === "Проблема доставки" ? "ghost" : "secondary"} + variant={status === "\u041F\u0440\u043E\u0431\u043B\u0435\u043C\u0430 \u0434\u043E\u0441\u0442\u0430\u0432\u043A\u0438" ? "ghost" : "secondary"} onClick={() => onStatusChange(status)} > {status} diff --git a/src/components/logistics/DeliverySetDetailPanel.jsx b/src/components/logistics/DeliverySetDetailPanel.jsx new file mode 100644 index 0000000..574bb2c --- /dev/null +++ b/src/components/logistics/DeliverySetDetailPanel.jsx @@ -0,0 +1,132 @@ +import React from "react"; +import { Badge } from "../UI/Badge"; +import { Button } from "../UI/Button"; +import { Panel } from "../UI/Panel"; +import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews"; + +const PRODUCTION_STEP_LABELS = { + sourceProductionAt: "\u0417\u0430\u043F\u0443\u0441\u043A \u043F\u0440\u043E\u0438\u0437\u0432\u043E\u0434\u0441\u0442\u0432\u0430", + sourceSawAt: "\u0420\u0430\u0441\u043A\u0440\u043E\u0439", + sourceGlueAt: "\u0421\u043A\u043B\u0435\u0439\u043A\u0430", + sourceHGlueAt: "H-\u0441\u043A\u043B\u0435\u0439\u043A\u0430", + sourceCurveAt: "\u041A\u0440\u0438\u0432\u043E\u043B\u0438\u043D\u0435\u0439\u043D\u044B\u0435", + sourceAcceptAt: "\u041F\u0440\u0438\u0451\u043C\u043A\u0430 \u041E\u0422\u041A", + sourceShipAt: "\u041E\u0442\u0433\u0440\u0443\u0437\u043A\u0430", +}; + +const formatStepDate = (iso) => { + if (!iso) { + return null; + } + + return new Date(iso).toLocaleDateString("ru-RU", { + day: "numeric", + month: "short", + }); +}; + +export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => { + if (!deliverySet) { + return null; + } + + const bucketLabel = DELIVERY_SET_BUCKET_LABELS[deliverySet.status] || deliverySet.status; + + return ( + <div className="space-y-5"> + <Panel className="space-y-4 p-6"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div> + <h3 className="text-lg font-semibold">{deliverySet.name}</h3> + <p className="text-sm text-[var(--color-text-muted)]"> + {deliverySet.sourceCustomerCity || "\u2014"} \u00B7 {deliverySet.orderCount}{" "} + {deliverySet.orderCount === 1 ? "заказ" : deliverySet.orderCount < 5 ? "заказа" : "заказов"} в наборе + </p> + </div> + <div className="flex flex-wrap gap-2"> + <Badge tone={deliverySet.status === "ready_to_launch" ? "accent" : "neutral"}> + {bucketLabel} + </Badge> + {deliverySet.readyAt ? ( + <Badge tone="neutral"> + Готов с {formatStepDate(deliverySet.readyAt)} + </Badge> + ) : null} + </div> + </div> + + {deliverySet.linkedBillTexts ? ( + <div className="text-sm text-[var(--color-text-muted)]"> + Связанные счета: {deliverySet.linkedBillTexts} + </div> + ) : null} + + {deliverySet.readyReason ? ( + <div className="text-sm text-[var(--color-text-muted)]"> + {deliverySet.readyReason === "all_accepted" + ? "Все заказы набора приняты ОТК, можно запускать доставку." + : "Не все заказы набора ещё приняты ОТК."} + </div> + ) : null} + </Panel> + + {deliverySet.orders.map((order) => ( + <Panel key={order.id} className="space-y-3 p-5"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div> + <div className="font-semibold">{order.orderNumber}</div> + {order.sourceFieldSummary?.sourceOrderNumber ? ( + <div className="text-sm text-[var(--color-text-muted)]"> + 1С: {order.sourceFieldSummary.sourceOrderNumber} + </div> + ) : null} + </div> + <Badge tone="neutral">{order.status}</Badge> + </div> + + <div className="grid gap-2 md:grid-cols-3"> + {Object.entries(PRODUCTION_STEP_LABELS).map(([key, label]) => { + const value = order.sourceFieldSummary?.[key]; + if (!value) { + return null; + } + + return ( + <div key={key} className="text-sm"> + <span className="text-[var(--color-text-muted)]">{label}:</span>{" "} + <span className="font-medium">{formatStepDate(value)}</span> + </div> + ); + })} + </div> + + {order.sourceFieldSummary?.sourceCustomerPhone ? ( + <div className="text-sm text-[var(--color-text-muted)]"> + \u260E {order.sourceFieldSummary.sourceCustomerPhone} + {order.sourceFieldSummary.sourceCustomerEmail + ? ` \u00B7 ${order.sourceFieldSummary.sourceCustomerEmail}` + : ""} + </div> + ) : null} + + {order.deliverySlots?.length ? ( + <div className="text-sm"> + <span className="text-[var(--color-text-muted)]">Слот:</span>{" "} + <span className="font-medium"> + {order.deliverySlots[0].date} \u00B7 {order.deliverySlots[0].time} + </span> + </div> + ) : null} + </Panel> + ))} + + {onClose ? ( + <div className="flex justify-end"> + <Button variant="ghost" onClick={onClose}> + Закрыть + </Button> + </div> + ) : null} + </div> + ); +}; \ No newline at end of file diff --git a/src/components/logistics/DeliverySetDetailPanel.test.jsx b/src/components/logistics/DeliverySetDetailPanel.test.jsx new file mode 100644 index 0000000..b2b7ea9 --- /dev/null +++ b/src/components/logistics/DeliverySetDetailPanel.test.jsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +describe("DeliverySetDetailPanel", () => { + it("exposes linked source order fields in delivery set items", () => { + const mockSetItem = { + id: "order-1", + orderNumber: "CD-240031", + sourceOrderNumber: "УН-00031", + sourceAcceptAt: "2026-04-12T14:00:00Z", + sourceShipAt: null, + sourceFieldSummary: { + sourceOrderNumber: "УН-00031", + sourceProductionAt: "2026-04-11T09:00:00Z", + sourceSawAt: "2026-04-11T10:00:00Z", + }, + }; + + expect(mockSetItem.sourceFieldSummary.sourceOrderNumber).toBe("УН-00031"); + expect(mockSetItem.sourceFieldSummary.sourceProductionAt).toBe("2026-04-11T09:00:00Z"); + expect(mockSetItem.sourceFieldSummary.sourceSawAt).toBe("2026-04-11T10:00:00Z"); + }); + + it("computes delivery ready reason from source accept timestamps", () => { + const allAccepted = { sourceAcceptAt: "2026-04-12T14:00:00Z", sourceShipAt: null }; + const notAccepted = { sourceAcceptAt: null, sourceShipAt: null }; + + expect(allAccepted.sourceAcceptAt).toBeTruthy(); + expect(notAccepted.sourceAcceptAt).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx new file mode 100644 index 0000000..e5def0d --- /dev/null +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -0,0 +1,116 @@ +import React from "react"; +import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews"; +import { Badge } from "../UI/Badge"; +import { Panel } from "../UI/Panel"; + +const BUCKET_TONES = { + approaching: "neutral", + ready_to_launch: "accent", + awaiting_client: "warning", + manual_work: "danger", + agreed: "accent", + completed: "neutral", +}; + +const BUCKET_ICONS = { + approaching: "\u2192", + ready_to_launch: "\u2713", + awaiting_client: "\u23F3", + manual_work: "\u26A0", + agreed: "\u2B50", + completed: "\u2714", +}; + +export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) => { + const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS); + const totalSets = bucketKeys.reduce( + (sum, key) => sum + (deliverySetBuckets[key]?.length || 0), + 0, + ); + + return ( + <div className="space-y-6"> + <Panel className="flex items-center justify-between p-5"> + <div> + <h2 className="text-lg font-semibold">Наборы доставки</h2> + <p className="mt-1 text-sm text-[var(--color-text-muted)]"> + Группировка импортированных заказов по клиентским наборам. Каждый набор запускается в доставку целиком после приёмки всех заказов. + </p> + </div> + <Badge tone="neutral">{totalSets} наборов</Badge> + </Panel> + + <div className="grid gap-6 xl:grid-cols-2"> + {bucketKeys.map((bucketKey) => { + const sets = deliverySetBuckets[bucketKey] || []; + const label = DELIVERY_SET_BUCKET_LABELS[bucketKey]; + const tone = BUCKET_TONES[bucketKey]; + const icon = BUCKET_ICONS[bucketKey]; + + if (!sets.length) { + return ( + <Panel key={bucketKey} className="p-5 opacity-50"> + <div className="flex items-center gap-2"> + <span className="text-lg">{icon}</span> + <h3 className="font-semibold">{label}</h3> + </div> + <p className="mt-2 text-sm text-[var(--color-text-muted)]">Нет наборов</p> + </Panel> + ); + } + + return ( + <div key={bucketKey} className="space-y-3"> + <div className="flex items-center gap-2"> + <span className="text-lg">{icon}</span> + <h3 className="font-semibold">{label}</h3> + <Badge tone={tone}>{sets.length}</Badge> + </div> + + {sets.map((set) => ( + <button + key={set.key} + className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5 text-left transition hover:bg-[var(--color-accent-soft)]" + onClick={() => { + if (onSelectSet) { + onSelectSet(set); + } + }} + type="button" + > + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0"> + <div className="font-semibold truncate">{set.name}</div> + <div className="mt-1 text-sm text-[var(--color-text-muted)]"> + {set.sourceCustomerCity || "\u2014"} \u00B7 {set.orderCount}{" "} + {set.orderCount === 1 ? "заказ" : set.orderCount < 5 ? "заказа" : "заказов"} + </div> + {set.linkedBillTexts ? ( + <div className="mt-1 text-xs text-[var(--color-text-muted)]"> + Связанные счета: {set.linkedBillTexts} + </div> + ) : null} + </div> + <Badge tone={tone}>{label}</Badge> + </div> + + <div className="mt-3 flex flex-wrap gap-2"> + {set.orders.map((order) => ( + <span + key={order.id} + className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]" + > + {order.orderNumber} + {order.sourceOrderNumber ? ` (${order.sourceOrderNumber})` : ""} + </span> + ))} + </div> + </button> + ))} + </div> + ); + })} + </div> + </div> + ); +}; \ No newline at end of file diff --git a/src/components/logistics/LogisticsReadinessBoard.test.jsx b/src/components/logistics/LogisticsReadinessBoard.test.jsx new file mode 100644 index 0000000..46a1c7f --- /dev/null +++ b/src/components/logistics/LogisticsReadinessBoard.test.jsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews"; + +describe("LogisticsReadinessBoard", () => { + it("renders all delivery-set bucket labels from the model", () => { + const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS); + expect(bucketKeys).toContain("approaching"); + expect(bucketKeys).toContain("ready_to_launch"); + expect(bucketKeys).toContain("awaiting_client"); + expect(bucketKeys).toContain("manual_work"); + expect(bucketKeys).toContain("agreed"); + expect(bucketKeys).toContain("completed"); + expect(bucketKeys).toHaveLength(6); + }); + + it("renders bucket labels in Russian", () => { + expect(DELIVERY_SET_BUCKET_LABELS.approaching).toBe("На подходе"); + expect(DELIVERY_SET_BUCKET_LABELS.ready_to_launch).toBe("Готово к запуску"); + expect(DELIVERY_SET_BUCKET_LABELS.awaiting_client).toBe("Ожидает клиента"); + expect(DELIVERY_SET_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа"); + expect(DELIVERY_SET_BUCKET_LABELS.agreed).toBe("Согласовано"); + expect(DELIVERY_SET_BUCKET_LABELS.completed).toBe("Завершено"); + }); +}); \ No newline at end of file diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index b9badc5..9bd416c 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -16,7 +16,8 @@ import { SegmentedTabs } from "../UI/SegmentedTabs"; import { Select } from "../UI/Select"; import { ChatTimeline } from "../chat/ChatTimeline"; -const resolveUserName = (userId) => demoUsers.find((user) => user.id === userId)?.name || "Не назначен"; +const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); +const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен"; const splitItem = (item) => { const [name, quantity] = item.split("|").map((part) => part.trim()); return { @@ -33,6 +34,7 @@ export const OrderDetailPanel = ({ onClientMessage, onInternalMessage, onOrderNote, + users, }) => { const [nextStatus, setNextStatus] = React.useState(order?.status || "Новый"); const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || ""); @@ -76,7 +78,7 @@ export const OrderDetailPanel = ({ role: currentUser.role, }); const canAssignDriver = currentUser.role === "logistician" || currentUser.role === "admin"; - const drivers = demoUsers.filter((user) => user.role === "driver"); + const drivers = getUsers(users).filter((user) => user.role === "driver"); return ( <div className="space-y-5"> @@ -97,15 +99,15 @@ export const OrderDetailPanel = ({ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div> <p className="text-xs text-[var(--color-text-muted)]">Менеджер</p> - <p className="mt-1 font-medium">{resolveUserName(order.managerId)}</p> + <p className="mt-1 font-medium">{resolveUserName(users, order.managerId)}</p> </div> <div> <p className="text-xs text-[var(--color-text-muted)]">Логист</p> - <p className="mt-1 font-medium">{resolveUserName(order.logisticianIds[0])}</p> + <p className="mt-1 font-medium">{resolveUserName(users, order.logisticianIds?.[0])}</p> </div> <div> <p className="text-xs text-[var(--color-text-muted)]">Водитель</p> - <p className="mt-1 font-medium">{resolveUserName(order.assignedDriverId)}</p> + <p className="mt-1 font-medium">{resolveUserName(users, order.assignedDriverId)}</p> </div> <div> <p className="text-xs text-[var(--color-text-muted)]">Дата создания</p> diff --git a/src/components/orders/OrderDetailPanel.test.jsx b/src/components/orders/OrderDetailPanel.test.jsx new file mode 100644 index 0000000..155438a --- /dev/null +++ b/src/components/orders/OrderDetailPanel.test.jsx @@ -0,0 +1,102 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OrderDetailPanel } from "./OrderDetailPanel"; + +const order = { + id: "o-1", + orderNumber: "CD-240031", + status: "Ожидает согласования доставки", + deliveryAgreementStatus: "Ожидание ответа", + managerId: "u-manager", + logisticianIds: ["u-logistics"], + assignedDriverId: null, + createdAt: "2026-03-15T08:00:00Z", + scheduledDelivery: "2026-03-16T09:00:00Z", + customer: { + name: "Мария Волкова", + phone: "+7 978 000-12-31", + address: "Симферополь", + messenger: "Телеграм", + }, + items: ["Кухня | 1 шт"], + chatMessages: [], + internalMessages: [], + orderNotes: [], + history: [], +}; + +describe("OrderDetailPanel", () => { + it("prioritizes status management in the mobile overview layout", () => { + const markup = renderToStaticMarkup( + <OrderDetailPanel + order={order} + currentUser={{ id: "u-manager", name: "Анна", role: "manager" }} + onStatusChange={() => {}} + onClientMessage={() => {}} + onInternalMessage={() => {}} + onOrderNote={() => {}} + />, + ); + + expect(markup).toContain("order-1"); + expect(markup).toContain("order-2"); + expect(markup).toContain("xl:order-none"); + }); + + it("does not crash when an order contains invalid date strings", () => { + const markup = renderToStaticMarkup( + <OrderDetailPanel + order={{ + ...order, + createdAt: "2026-03-18T010:00:00Z", + scheduledDelivery: "not-a-date", + orderNotes: [ + { + id: "note-1", + authorName: "Анна", + text: "Проверка даты", + createdAt: "broken-date", + }, + ], + }} + currentUser={{ id: "u-manager", name: "Анна", role: "manager" }} + onStatusChange={() => {}} + onClientMessage={() => {}} + onInternalMessage={() => {}} + onOrderNote={() => {}} + />, + ); + + expect(markup).toContain("Не указано"); + }); + + it("shows driver assignment controls for logisticians and admins only", () => { + const logisticianMarkup = renderToStaticMarkup( + <OrderDetailPanel + order={order} + currentUser={{ id: "u-logistics", name: "Ольга", role: "logistician" }} + onStatusChange={() => {}} + onAssignDriver={() => {}} + onClientMessage={() => {}} + onInternalMessage={() => {}} + onOrderNote={() => {}} + />, + ); + const managerMarkup = renderToStaticMarkup( + <OrderDetailPanel + order={order} + currentUser={{ id: "u-manager", name: "Анна", role: "manager" }} + onStatusChange={() => {}} + onAssignDriver={() => {}} + onClientMessage={() => {}} + onInternalMessage={() => {}} + onOrderNote={() => {}} + />, + ); + + expect(logisticianMarkup).toContain("Назначение водителя"); + expect(logisticianMarkup).toContain("Артём Громов"); + expect(managerMarkup).not.toContain("Назначение водителя"); + }); +}); diff --git a/src/components/orders/OrderEditorPanel.test.jsx b/src/components/orders/OrderEditorPanel.test.jsx new file mode 100644 index 0000000..8bf481f --- /dev/null +++ b/src/components/orders/OrderEditorPanel.test.jsx @@ -0,0 +1,22 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OrderEditorPanel } from "./OrderEditorPanel"; + +describe("OrderEditorPanel", () => { + it("renders single-column fields and sticky mobile action bar", () => { + const markup = renderToStaticMarkup( + <OrderEditorPanel + currentUser={{ id: "u-manager", name: "Анна", role: "manager" }} + selectedOrder={null} + onCreateOrder={() => {}} + onSaveOrder={() => {}} + createOnly + />, + ); + + expect(markup).toContain("grid gap-3 md:grid-cols-2"); + expect(markup).toContain("sticky bottom-0"); + expect(markup).toContain("pb-24"); + }); +}); diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index d124fd1..d0dff44 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -9,8 +9,7 @@ import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; import { Select } from "../UI/Select"; -const logisticians = demoUsers.filter((user) => user.role === "logistician"); -const managers = demoUsers.filter((user) => user.role === "manager"); +const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"]; const responsibilityRoles = Object.entries(ROLE_LABELS).filter(([role]) => role !== "admin"); const agingOptions = [ @@ -18,8 +17,11 @@ const agingOptions = [ { key: "critical", label: "Просрочены" }, ]; -export const OrderFilters = ({ filters, setFilters }) => { +export const OrderFilters = ({ filters, setFilters, users }) => { const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false); + const liveUsers = getUsers(users); + const logisticians = liveUsers.filter((user) => user.role === "logistician"); + const managers = liveUsers.filter((user) => user.role === "manager" || user.role === "admin"); const activeChips = [ filters.status !== "all" ? { key: "status", label: filters.status } : null, diff --git a/src/components/orders/OrderFilters.test.jsx b/src/components/orders/OrderFilters.test.jsx new file mode 100644 index 0000000..4222211 --- /dev/null +++ b/src/components/orders/OrderFilters.test.jsx @@ -0,0 +1,34 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OrderFilters } from "./OrderFilters"; + +describe("OrderFilters", () => { + it("renders search, stage, responsibility and aging filters", () => { + const markup = renderToStaticMarkup( + <OrderFilters + filters={{ + query: "", + status: "all", + stage: "all", + ownerRole: "all", + agingState: "all", + managerId: "all", + logisticianId: "all", + messenger: "all", + }} + setFilters={() => {}} + />, + ); + + expect(markup).toContain("Поиск по заявке, клиенту, телефону"); + expect(markup).toContain("Все этапы"); + expect(markup).toContain("Все зоны ответственности"); + expect(markup).toContain("Без фильтра по SLA"); + expect(markup).toContain("Фильтры"); + expect(markup).toContain("Активные фильтры"); + expect(markup).toContain("Статус"); + expect(markup).toContain("Этап"); + expect(markup).toContain("Ответственный отдел"); + }); +}); diff --git a/src/components/orders/OrdersCalendarView.test.jsx b/src/components/orders/OrdersCalendarView.test.jsx new file mode 100644 index 0000000..825804c --- /dev/null +++ b/src/components/orders/OrdersCalendarView.test.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OrdersCalendarView } from "./OrdersCalendarView"; + +const orders = [ + { + id: "o-1", + orderNumber: "CD-240031", + customer: { name: "Мария Волкова" }, + deliverySlots: [{ date: "2026-03-14" }], + scheduledDelivery: "2026-03-14T09:00:00Z", + }, +]; + +describe("OrdersCalendarView", () => { + it("renders mobile agenda list alongside desktop calendar grid", () => { + const markup = renderToStaticMarkup( + <OrdersCalendarView orders={orders} onOpenOrder={() => {}} />, + ); + + expect(markup).toContain("md:hidden"); + expect(markup).toContain("hidden md:block"); + expect(markup).toContain("Заказы по дням"); + expect(markup).toContain("CD-240031"); + }); +}); diff --git a/src/components/orders/OrdersKanbanBoard.jsx b/src/components/orders/OrdersKanbanBoard.jsx new file mode 100644 index 0000000..cf99bec --- /dev/null +++ b/src/components/orders/OrdersKanbanBoard.jsx @@ -0,0 +1,206 @@ +import React from "react"; +import { WORKFLOW_STAGES } from "../../constants/deliveryWorkflow"; +import { cn } from "../../lib/cn"; +import { beginKanbanDrag } from "./ordersKanbanDrag"; +import { Badge } from "../UI/Badge"; +import { Button } from "../UI/Button"; +import { Panel } from "../UI/Panel"; +import { SegmentedTabs } from "../UI/SegmentedTabs"; +import { Select } from "../UI/Select"; + +const ROLE_ACCENT_CLASSNAMES = { + manager: "border-t-[#2f7ef7]", + production_lead: "border-t-[#d8912f]", + logistician: "border-t-[var(--color-accent)]", + driver: "border-t-[#7f56d9]", +}; + +const COLUMN_STAGE_CLASSNAMES = { + manager: "bg-[rgba(47,126,247,0.09)] border-[rgba(47,126,247,0.16)]", + production: "bg-[rgba(216,145,47,0.09)] border-[rgba(216,145,47,0.16)]", + logistics: "bg-[rgba(18,128,92,0.09)] border-[rgba(18,128,92,0.16)]", + delivery: "bg-[rgba(127,86,217,0.09)] border-[rgba(127,86,217,0.16)]", + completed: "bg-[rgba(16,33,27,0.05)] border-[var(--color-border)]", +}; + +const getAgingClassName = (agingState) => { + if (agingState === "critical") { + return "ring-1 ring-[rgba(201,61,61,0.45)]"; + } + + if (agingState === "warning") { + return "ring-1 ring-[rgba(191,123,33,0.45)]"; + } + + return ""; +}; + +const getAgingTone = (agingState) => { + if (agingState === "critical") { + return "danger"; + } + + if (agingState === "warning") { + return "warning"; + } + + return "neutral"; +}; + +const getOrderCity = (order) => order.customer?.address?.split(",")?.[0]?.trim() || "Город не указан"; + +export const OrdersKanbanBoard = ({ + columns, + currentMode, + departmentFilter, + notice, + onDepartmentFilterChange, + onModeChange, + onOpenOrder, + onDragStart, + onDragEnd, + onDragOverColumn, + onDragLeaveColumn, + onDropColumn, + dropColumnKey, +}) => { + const scrollViewportRef = React.useRef(null); + const modeTabs = [ + { key: "by_stage", label: "По этапам" }, + { key: "by_status", label: "По статусам" }, + ]; + const scrollKanban = (direction) => { + scrollViewportRef.current?.scrollBy({ + left: direction * 320, + behavior: "smooth", + }); + }; + + return ( + <div className="max-w-full space-y-4"> + {notice ? ( + <Panel className="border-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.08)] p-4"> + <div className="text-sm font-semibold text-[var(--color-text)]">{notice.title}</div> + <div className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">{notice.description}</div> + </Panel> + ) : null} + + <div className="flex flex-wrap items-center justify-between gap-3"> + <SegmentedTabs items={modeTabs} activeKey={currentMode} onChange={onModeChange} /> + <div className="flex w-full flex-wrap items-center justify-end gap-3 lg:w-auto"> + <div className="w-full sm:w-[240px]"> + <Select value={departmentFilter} onChange={(event) => onDepartmentFilterChange(event.target.value)}> + <option value="all">Все отделы</option> + {WORKFLOW_STAGES.map((stage) => ( + <option key={stage.key} value={stage.key}> + {stage.label} + </option> + ))} + </Select> + </div> + <div className="hidden items-center gap-2 lg:flex"> + <Button + size="sm" + variant="secondary" + aria-label="Прокрутить канбан влево" + onClick={() => scrollKanban(-1)} + > + ← + </Button> + <Button + size="sm" + variant="secondary" + aria-label="Прокрутить канбан вправо" + onClick={() => scrollKanban(1)} + > + → + </Button> + </div> + </div> + </div> + + <div + ref={scrollViewportRef} + className="max-w-full overflow-x-auto pb-2 scroll-smooth" + role="region" + aria-label="Канбан-лента" + > + <div + className="flex min-w-max gap-3" + > + {columns.map((column) => ( + <Panel + key={column.key} + className={cn( + "w-[280px] shrink-0 rounded-[20px] p-3", + COLUMN_STAGE_CLASSNAMES[column.stageKey] || COLUMN_STAGE_CLASSNAMES.completed, + )} + data-stage={column.stageKey} + > + <div className="mb-3 flex items-start justify-between gap-3 px-1"> + <div> + <h3 className="text-sm font-semibold text-[var(--color-text)]">{column.title}</h3> + <div className="mt-2 flex flex-wrap gap-2"> + {column.warningCount ? <Badge tone="warning">Зависают: {column.warningCount}</Badge> : null} + {column.criticalCount ? <Badge tone="danger">Просрочено: {column.criticalCount}</Badge> : null} + </div> + </div> + <span className="text-sm text-[var(--color-text-muted)]">{column.items.length}</span> + </div> + <div + className={cn( + "min-h-[280px] space-y-2 rounded-[16px] border border-dashed p-2 transition", + dropColumnKey === column.key + ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]" + : "border-[var(--color-border)] bg-[var(--color-surface-strong)]", + )} + onDragOver={(event) => { + event.preventDefault(); + onDragOverColumn(column.key); + }} + onDragLeave={() => onDragLeaveColumn(column.key)} + onDrop={(event) => { + event.preventDefault(); + onDropColumn(event, column); + }} + > + {column.items.map((order) => ( + <article + key={order.id} + className={cn( + "cursor-grab rounded-[16px] border border-[var(--color-border)] border-t-4 bg-[var(--color-surface-strong)] px-4 py-4 text-left shadow-sm transition hover:shadow-md active:cursor-grabbing", + ROLE_ACCENT_CLASSNAMES[order.ownerRole] || "border-t-[var(--color-border)]", + getAgingClassName(order.agingState), + )} + onClick={() => onOpenOrder(order.id)} + onDragStart={(event) => beginKanbanDrag(event, order.id, onDragStart)} + onDragEnd={onDragEnd} + draggable + > + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <div className="font-medium">{order.orderNumber}</div> + <div className="mt-1 text-sm text-[var(--color-text-muted)]"> + {order.customer.name} + </div> + </div> + <Badge tone={getAgingTone(order.agingState)}>{order.statusAgeLabel}</Badge> + </div> + + <div className="mt-3 text-sm text-[var(--color-text-muted)]"> + {getOrderCity(order)} + </div> + + <div className="mt-3 flex flex-wrap gap-2"> + <Badge tone="neutral">{order.status}</Badge> + </div> + </article> + ))} + </div> + </Panel> + ))} + </div> + </div> + </div> + ); +}; diff --git a/src/components/orders/OrdersKanbanBoard.test.jsx b/src/components/orders/OrdersKanbanBoard.test.jsx new file mode 100644 index 0000000..015a06d --- /dev/null +++ b/src/components/orders/OrdersKanbanBoard.test.jsx @@ -0,0 +1,86 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OrdersKanbanBoard } from "./OrdersKanbanBoard"; + +const kanbanColumns = [ + { + key: "logistics", + title: "Логистика", + stageKey: "logistics", + statuses: ["Ожидает согласования доставки", "Проблема доставки"], + warningCount: 1, + criticalCount: 1, + items: [ + { + id: "1", + orderNumber: "CD-240031", + customer: { name: "Мария Волкова", address: "Симферополь, ул. Тургенева, 18" }, + items: ["Кухня | 1 шт"], + status: "Ожидает согласования доставки", + ownerRole: "logistician", + stageLabel: "Логистика", + agingState: "warning", + statusAgeLabel: "2д в статусе", + }, + { + id: "2", + orderNumber: "CD-240033", + customer: { name: "Александр Савин", address: "Ялта, ул. Чехова, 9" }, + items: ["Стеклопакет | 2 шт"], + status: "Проблема доставки", + ownerRole: "logistician", + stageLabel: "Логистика", + agingState: "critical", + statusAgeLabel: "3д в статусе", + }, + ], + }, +]; + +describe("OrdersKanbanBoard", () => { + it("renders mode switch, responsibility marker and stalled labels", () => { + const markup = renderToStaticMarkup( + <OrdersKanbanBoard + columns={kanbanColumns} + currentMode="by_stage" + departmentFilter="all" + notice={{ + tone: "warning", + title: "Перенос недоступен", + description: "Сначала назначьте водителя, потом заказ можно передать в доставку.", + }} + onDepartmentFilterChange={() => {}} + onModeChange={() => {}} + onOpenOrder={() => {}} + onDragStart={() => {}} + onDragEnd={() => {}} + onDropColumn={() => {}} + dropColumnKey={null} + />, + ); + + expect(markup).toContain("По этапам"); + expect(markup).toContain("По статусам"); + expect(markup).toContain("Все отделы"); + expect(markup).toContain("Зависают: 1"); + expect(markup).toContain("Просрочено: 1"); + expect(markup).toContain("Логистика"); + expect(markup).toContain("2д в статусе"); + expect(markup).toContain("3д в статусе"); + expect(markup).toContain("Симферополь"); + expect(markup).toContain("Ялта"); + expect(markup).toContain("data-stage=\"logistics\""); + expect(markup).toContain("bg-[rgba(18,128,92,0.09)]"); + expect(markup).toContain("overflow-x-auto"); + expect(markup).toContain("max-w-full"); + expect(markup).toContain("w-[280px]"); + expect(markup).toContain("aria-label=\"Прокрутить канбан влево\""); + expect(markup).toContain("aria-label=\"Прокрутить канбан вправо\""); + expect(markup).toContain("Канбан-лента"); + expect(markup).toContain("Перенос недоступен"); + expect(markup).toContain("Сначала назначьте водителя"); + expect(markup).not.toContain(">Логист<"); + expect(markup).not.toContain("Кухня"); + }); +}); diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index 7f35150..5415695 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -5,14 +5,15 @@ import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; -const resolveUserName = (userId) => demoUsers.find((user) => user.id === userId)?.name || "Не назначен"; +const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); +const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен"; const buildOrderSummary = (order) => { const leadItem = order.items?.[0] || "Состав не указан"; const leadComment = order.orderNotes?.[0]?.text || order.comments?.[0] || "Без уточнений"; return `${leadItem}. ${leadComment}`; }; -export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => { +export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder, users }) => { return ( <Panel className="overflow-hidden p-0"> <div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-4"> @@ -46,7 +47,7 @@ export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => { <div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildOrderSummary(order)}</div> <div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-[var(--color-text-muted)]"> <span>{order.customer.phone}</span> - <span>{resolveUserName(order.managerId)}</span> + <span>{resolveUserName(users, order.managerId)}</span> <span>{formatDateTime(order.updatedAt)}</span> </div> </button> @@ -91,7 +92,7 @@ export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => { <td className="px-5 py-4"> <Badge tone={getStatusTone(order.status)}>{order.status}</Badge> </td> - <td className="px-5 py-4 text-sm">{resolveUserName(order.managerId)}</td> + <td className="px-5 py-4 text-sm">{resolveUserName(users, order.managerId)}</td> <td className="px-5 py-4 text-sm text-[var(--color-text-muted)]"> {formatDateTime(order.updatedAt)} </td> diff --git a/src/components/orders/OrdersTable.test.jsx b/src/components/orders/OrdersTable.test.jsx new file mode 100644 index 0000000..198b88b --- /dev/null +++ b/src/components/orders/OrdersTable.test.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OrdersTable } from "./OrdersTable"; + +const orders = [ + { + id: "o-1", + orderNumber: "CD-240031", + customer: { + name: "Мария Волкова", + phone: "+7 978 000-12-31", + messenger: "Телеграм", + }, + items: ["Кухня | 1 шт"], + orderNotes: [{ text: "Подъезд узкий" }], + comments: ["Нужен созвон"], + status: "Ожидает согласования доставки", + managerId: "u-manager", + updatedAt: "2026-03-15T08:00:00Z", + }, +]; + +describe("OrdersTable", () => { + it("renders desktop table and mobile card list", () => { + const markup = renderToStaticMarkup( + <OrdersTable orders={orders} selectedOrderId={null} onOpenOrder={() => {}} />, + ); + + expect(markup).toContain("hidden overflow-x-auto md:block"); + expect(markup).toContain("md:hidden"); + expect(markup).toContain("CD-240031"); + expect(markup).toContain("Мария Волкова"); + }); +}); diff --git a/src/components/orders/ordersKanbanDrag.js b/src/components/orders/ordersKanbanDrag.js new file mode 100644 index 0000000..47f0fcd --- /dev/null +++ b/src/components/orders/ordersKanbanDrag.js @@ -0,0 +1,14 @@ +export const beginKanbanDrag = (event, orderId, setDragOrderId) => { + if (event?.dataTransfer?.setData) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", orderId); + } + + setDragOrderId(orderId); +}; + +export const resolveDraggedOrderId = (event, fallbackOrderId) => { + const draggedId = event?.dataTransfer?.getData?.("text/plain"); + + return draggedId || fallbackOrderId || null; +}; diff --git a/src/components/orders/ordersKanbanDrag.test.js b/src/components/orders/ordersKanbanDrag.test.js new file mode 100644 index 0000000..7c430d8 --- /dev/null +++ b/src/components/orders/ordersKanbanDrag.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { beginKanbanDrag, resolveDraggedOrderId } from "./ordersKanbanDrag"; + +describe("ordersKanbanDrag", () => { + it("writes dragged order id into dataTransfer and local state", () => { + const setDragOrderId = vi.fn(); + const setData = vi.fn(); + const event = { + dataTransfer: { + effectAllowed: "none", + setData, + }, + }; + + beginKanbanDrag(event, "o-1001", setDragOrderId); + + expect(setData).toHaveBeenCalledWith("text/plain", "o-1001"); + expect(event.dataTransfer.effectAllowed).toBe("move"); + expect(setDragOrderId).toHaveBeenCalledWith("o-1001"); + }); + + it("prefers dataTransfer id and falls back to state value", () => { + expect( + resolveDraggedOrderId( + { + dataTransfer: { + getData: () => "o-1002", + }, + }, + "o-fallback", + ), + ).toBe("o-1002"); + + expect(resolveDraggedOrderId({}, "o-fallback")).toBe("o-fallback"); + }); +}); diff --git a/src/constants/deliveryWorkflow.contract.test.js b/src/constants/deliveryWorkflow.contract.test.js new file mode 100644 index 0000000..310e366 --- /dev/null +++ b/src/constants/deliveryWorkflow.contract.test.js @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + DELIVERY_SET_STATUSES, + ORDER_STATUS_META, + ORDER_STATUS_TRANSITIONS, + WORKFLOW_STAGES, +} from "./deliveryWorkflow"; +import { ROLE_PERMISSIONS } from "./roles"; + +describe("deliveryWorkflow contract: delivery-centric wording", () => { + it("describes workflow stages in delivery logistics terms", () => { + const stageKeys = WORKFLOW_STAGES.map((s) => s.key); + expect(stageKeys).toContain("import"); + expect(stageKeys).toContain("logistics"); + expect(stageKeys).toContain("delivery"); + expect(stageKeys).toContain("completed"); + }); + + it("uses import stage instead of manager for initial source orders", () => { + const importStage = WORKFLOW_STAGES.find((s) => s.key === "import"); + expect(importStage).toBeDefined(); + expect(importStage.label).toMatch(/1С|импорт|импортир/i); + }); + + it("logistician permissions describe delivery workspace actions", () => { + const permissions = ROLE_PERMISSIONS.logistician; + expect(permissions).toEqual( + expect.arrayContaining([ + expect.stringMatching(/доставка|доставк/i), + expect.stringMatching(/набор|комплект/i), + ]), + ); + }); + + it("driver permissions describe delivery execution actions", () => { + const permissions = ROLE_PERMISSIONS.driver; + expect(permissions).toEqual( + expect.arrayContaining([ + expect.stringMatching(/назначен|доставк|рейс/i), + expect.stringMatching(/результат|заверш/i), + ]), + ); + }); + + it("admin permissions include delivery set management", () => { + const permissions = ROLE_PERMISSIONS.admin; + expect(permissions).toEqual( + expect.arrayContaining([expect.stringMatching(/доставка|доставк/i)]), + ); + }); + + it("includes delivery-set statuses for readiness buckets", () => { + expect(DELIVERY_SET_STATUSES).toBeDefined(); + expect(DELIVERY_SET_STATUSES).toContain("approaching"); + expect(DELIVERY_SET_STATUSES).toContain("ready_to_launch"); + expect(DELIVERY_SET_STATUSES).toContain("awaiting_client"); + expect(DELIVERY_SET_STATUSES).toContain("manual_work"); + expect(DELIVERY_SET_STATUSES).toContain("agreed"); + expect(DELIVERY_SET_STATUSES).toContain("completed"); + }); + + it("order statuses reference 1C as the source of creation", () => { + expect(ORDER_STATUS_META["Новый"].comment).toMatch(/1С|импорт|импортир/i); + expect(ORDER_STATUS_META["Новый"].ownerRole).toBe("logistician"); + }); + + it("Готов к отгрузке is owned by logistician and starts logistics stage", () => { + expect(ORDER_STATUS_META["Готов к отгрузке"].ownerRole).toBe("logistician"); + expect(ORDER_STATUS_META["Готов к отгрузке"].stageKey).toBe("logistics"); + }); + + it("allows transition from Готов к отгрузке to delivery coordination", () => { + const transitions = ORDER_STATUS_TRANSITIONS["Готов к отгрузке"]; + expect(transitions).toContain("Ожидает согласования доставки"); + }); +}); \ No newline at end of file diff --git a/src/constants/deliveryWorkflow.js b/src/constants/deliveryWorkflow.js index f4d702d..ffcdd00 100644 --- a/src/constants/deliveryWorkflow.js +++ b/src/constants/deliveryWorkflow.js @@ -1,38 +1,47 @@ export const WORKFLOW_STAGES = [ - { key: "manager", label: "Менеджер" }, + { key: "import", label: "Импорт из 1С" }, { key: "production", label: "Производство" }, { key: "logistics", label: "Логистика" }, { key: "delivery", label: "Доставка" }, { key: "completed", label: "Завершено" }, ]; +export const DELIVERY_SET_STATUSES = [ + "approaching", + "ready_to_launch", + "awaiting_client", + "manual_work", + "agreed", + "completed", +]; + const getStageLabel = (stageKey) => WORKFLOW_STAGES.find((stage) => stage.key === stageKey)?.label || "Без этапа"; export const ORDER_STATUS_META = { "Новый": { - comment: "Заказ создан и ожидает проверки менеджером.", - ownerRole: "manager", - stageKey: "manager", - stageLabel: getStageLabel("manager"), + comment: "Заказ импортирован из 1С и ожидает проверки логистом.", + ownerRole: "logistician", + stageKey: "import", + stageLabel: getStageLabel("import"), warningAfterHours: 24, criticalAfterHours: 48, tone: "neutral", }, "Требует уточнения": { - comment: "В заказе не хватает данных, их должен уточнить менеджер.", - ownerRole: "manager", - stageKey: "manager", - stageLabel: getStageLabel("manager"), + comment: "В заказе не хватает данных, логисту нужно уточнить информацию.", + ownerRole: "logistician", + stageKey: "import", + stageLabel: getStageLabel("import"), warningAfterHours: 12, criticalAfterHours: 24, tone: "warning", }, "Подтверждён менеджером": { - comment: "Менеджер проверил заказ и передал его дальше в работу.", - ownerRole: "manager", - stageKey: "manager", - stageLabel: getStageLabel("manager"), + comment: "Заказ проверен и подтверждён, готов к передаче в работу.", + ownerRole: "logistician", + stageKey: "import", + stageLabel: getStageLabel("import"), warningAfterHours: 12, criticalAfterHours: 24, tone: "accent", @@ -47,7 +56,7 @@ export const ORDER_STATUS_META = { tone: "neutral", }, "В производстве": { - comment: "Заказ находится в изготовлении.", + comment: "Заказ находится в изготовлении на производстве.", ownerRole: "production_lead", stageKey: "production", stageLabel: getStageLabel("production"), @@ -56,10 +65,10 @@ export const ORDER_STATUS_META = { tone: "accent", }, "Готов к отгрузке": { - comment: "Производство завершено, можно запускать согласование доставки.", - ownerRole: "production_lead", - stageKey: "production", - stageLabel: getStageLabel("production"), + comment: "Производство завершено, заказ готов к запуску доставки логистом.", + ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), warningAfterHours: 8, criticalAfterHours: 24, tone: "accent", @@ -83,7 +92,7 @@ export const ORDER_STATUS_META = { tone: "warning", }, "Доставка согласована": { - comment: "Клиент подтвердил доставку, логист может назначать рейс.", + comment: "Клиент подтвердил доставку, логист назначает водителя и рейс.", ownerRole: "logistician", stageKey: "logistics", stageLabel: getStageLabel("logistics"), @@ -92,7 +101,7 @@ export const ORDER_STATUS_META = { tone: "accent", }, "Передан логисту": { - comment: "Согласование не завершилось автоматически, заказ передан логисту для ручной работы.", + comment: "Автоматическое согласование не завершилось, заказ передан логисту на ручную обработку.", ownerRole: "logistician", stageKey: "logistics", stageLabel: getStageLabel("logistics"), @@ -101,8 +110,8 @@ export const ORDER_STATUS_META = { tone: "warning", }, "Назначен водитель": { - comment: "Логист распределил заказ на конкретного водителя.", - ownerRole: "logistician", + comment: "Логист назначил водителя на доставку.", + ownerRole: "driver", stageKey: "delivery", stageLabel: getStageLabel("delivery"), warningAfterHours: 12, @@ -110,7 +119,7 @@ export const ORDER_STATUS_META = { tone: "accent", }, Загружен: { - comment: "Заказ физически загружен в транспорт.", + comment: "Заказ загружен в транспорт, водитель готов к выезду.", ownerRole: "driver", stageKey: "delivery", stageLabel: getStageLabel("delivery"), @@ -137,7 +146,7 @@ export const ORDER_STATUS_META = { tone: "accent", }, Закрыт: { - comment: "Цикл заказа завершён и больше не требует действий.", + comment: "Цикл доставки завершён и больше не требует действий.", ownerRole: "logistician", stageKey: "completed", stageLabel: getStageLabel("completed"), @@ -146,8 +155,8 @@ export const ORDER_STATUS_META = { tone: "neutral", }, Отменён: { - comment: "Заказ отменён и выведен из процесса.", - ownerRole: "manager", + comment: "Заказ отменён и выведен из процесса доставки.", + ownerRole: "logistician", stageKey: "completed", stageLabel: getStageLabel("completed"), warningAfterHours: null, @@ -155,7 +164,7 @@ export const ORDER_STATUS_META = { tone: "danger", }, "Проблема доставки": { - comment: "На этапе доставки возникла проблема и нужен ручной разбор.", + comment: "На этапе доставки возникла проблема, требуется ручной разбор логистом.", ownerRole: "logistician", stageKey: "logistics", stageLabel: getStageLabel("logistics"), @@ -227,6 +236,8 @@ export const ROLE_TRANSITION_TARGETS = { manager: ORDER_STATUSES, production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"], logistician: [ + "Новый", + "Требует уточнения", "Ожидает ответа клиента", "Ожидает согласования доставки", "Доставка согласована", @@ -249,6 +260,7 @@ export const PRODUCTION_STATUSES = [ export const LOGISTICS_STATUSES = [ "Готов к отгрузке", + "Ожидает ответа клиента", "Ожидает согласования доставки", "Доставка согласована", "Назначен водитель", diff --git a/src/constants/roles.js b/src/constants/roles.js index 780d09a..8437ee0 100644 --- a/src/constants/roles.js +++ b/src/constants/roles.js @@ -2,33 +2,33 @@ export const ROLE_LABELS = { manager: "Менеджер", production_lead: "Начальник производства", logistician: "Логист", - driver: "Водитель", + driver: "Водитель-экспедитор", admin: "Администратор", }; export const ROLE_PERMISSIONS = { manager: [ - "Создание и редактирование заказов", + "Просмотр импортированных заказов", "Поиск и фильтрация по заказам", - "Комментарии и контроль подтверждения", + "Комментарии и эскалации", ], production_lead: [ "Очередь производства", "Изменение статусов производства", - "Контроль готовности к доставке", + "Контроль готовности к отгрузке", ], logistician: [ - "Согласование доставки с клиентом", - "Назначение водителя и рейса", - "Разбор проблемных доставок", + "Доставка: наборы и слоты", + "Согласование с клиентом и назначение рейса", + "Разбор проблемных доставок и ручная работа", ], driver: [ - "Просмотр назначенных доставок", - "Подтверждение загрузки и выезда", + "Назначенные доставки и маршрут", + "Загрузка, выезд и завершение рейса", "Фиксация результата доставки", ], admin: [ - "Полный доступ к заказам", + "Полный доступ к заказам и доставкам", "Управление пользователями и ролями", "Логи, ошибки и история действий", ], diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index d1fdd89..636c44f 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -8,6 +8,25 @@ const STORAGE_KEY = "construction-auth-demo-user"; export const DEMO_LOGIN_EMAIL = "demo@local"; export const MISSING_PROFILE_ERROR = "Профиль пользователя не найден. Обратитесь к администратору."; export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя."; +export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору."; + +const UNKNOWN_EMAIL_ERROR_PATTERNS = [ + /user not found/i, + /email not found/i, + /user does not exist/i, + /invalid login credentials/i, + /signup is disabled/i, + /sign up is disabled/i, +]; + +export const normalizeOtpError = (error) => { + const message = error instanceof Error ? error.message : String(error || ""); + if (UNKNOWN_EMAIL_ERROR_PATTERNS.some((pattern) => pattern.test(message))) { + return new Error(UNKNOWN_EMAIL_ERROR); + } + + return error instanceof Error ? error : new Error(message || PROFILE_LOAD_ERROR); +}; export const buildOtpRequestPayload = (email) => ({ email, @@ -117,7 +136,7 @@ export const AuthProvider = ({ children }) => { const { error } = await supabase.auth.signInWithOtp(buildOtpRequestPayload(email)); if (error) { - throw error; + throw normalizeOtpError(error); } } else { localStorage.setItem("construction-auth-role-hint", roleHint || "manager"); @@ -126,7 +145,9 @@ export const AuthProvider = ({ children }) => { setIsOtpSent(true); return { success: true }; } catch (error) { - return { success: false, error }; + const normalizedError = normalizeOtpError(error); + setAuthError(normalizedError.message); + return { success: false, error: normalizedError }; } finally { setIsLoading(false); } @@ -143,7 +164,7 @@ export const AuthProvider = ({ children }) => { }); if (error) { - throw error; + throw normalizeOtpError(error); } return { success: Boolean(data.session) }; @@ -158,7 +179,9 @@ export const AuthProvider = ({ children }) => { setUser(demoUser); return { success: true }; } catch (error) { - return { success: false, error }; + const normalizedError = normalizeOtpError(error); + setAuthError(normalizedError.message); + return { success: false, error: normalizedError }; } finally { setIsLoading(false); } diff --git a/src/context/AuthContext.test.js b/src/context/AuthContext.test.js index 45a2ba1..327e421 100644 --- a/src/context/AuthContext.test.js +++ b/src/context/AuthContext.test.js @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { DEMO_LOGIN_EMAIL, + UNKNOWN_EMAIL_ERROR, buildOtpRequestPayload, mapProfileToAuthUser, + normalizeOtpError, resolveDemoUser, resolveLoginEmail, } from "./AuthContext"; @@ -60,3 +62,22 @@ describe("mapProfileToAuthUser", () => { }); }); }); + + +describe("normalizeOtpError", () => { + it("maps unknown email errors to the admin hint", () => { + expect(normalizeOtpError(new Error("User not found")).message).toBe(UNKNOWN_EMAIL_ERROR); + }); + + it("maps invalid login credentials to the admin hint", () => { + expect(normalizeOtpError(new Error("Invalid login credentials")).message).toBe(UNKNOWN_EMAIL_ERROR); + }); + + it("maps signup disabled errors to the admin hint", () => { + expect(normalizeOtpError(new Error("Sign up is disabled")).message).toBe(UNKNOWN_EMAIL_ERROR); + }); + + it("preserves other error messages", () => { + expect(normalizeOtpError(new Error("Network error")).message).toBe("Network error"); + }); +}); diff --git a/src/layouts/AppShell.test.jsx b/src/layouts/AppShell.test.jsx new file mode 100644 index 0000000..10c6e05 --- /dev/null +++ b/src/layouts/AppShell.test.jsx @@ -0,0 +1,36 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { AppShell } from "./AppShell"; + +vi.mock("../components/UI/ThemeToggle", () => ({ + ThemeToggle: () => <button type="button">Theme</button>, +})); + +describe("AppShell", () => { + it("renders mobile header and bottom navigation alongside desktop shell", () => { + const markup = renderToStaticMarkup( + <AppShell + user={{ name: "Анна Мельник", role: "manager" }} + onSignOut={() => {}} + navItems={[ + { key: "overview", label: "Обзор" }, + { key: "orders", label: "Заказы", badge: "7" }, + ]} + activeSection="orders" + onSectionChange={() => {}} + sectionMeta={{ label: "Заказы", description: "Рабочая область заказов" }} + > + <div>content</div> + </AppShell>, + ); + + expect(markup).toContain("xl:grid-cols-[220px_1fr]"); + expect(markup).toContain("xl:flex"); + expect(markup).toContain("xl:hidden"); + expect(markup).toContain("fixed inset-x-0 bottom-0"); + expect(markup).toContain("min-w-0"); + expect(markup).toContain("Рабочая область"); + expect(markup).toContain("Заказы"); + }); +}); diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx new file mode 100644 index 0000000..524c250 --- /dev/null +++ b/src/pages/ClientDeliveryPage.jsx @@ -0,0 +1,211 @@ +import React from "react"; +import { useParams, useSearchParams } from "react-router-dom"; +import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow"; +import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; +import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice"; +import { Panel } from "../components/UI/Panel"; +import { + confirmDeliveryChoice, + fetchDeliveryInvitation, +} from "../services/deliveryInvitationApi"; + +const groupSlotsFromInvitation = (invitation) => { + if (!invitation) { + return []; + } + + const rawSlots = invitation.availableSlots || []; + const deliveryDate = invitation.deliveryDate; + const deliveryTime = invitation.deliveryTime; + + if (!rawSlots.length && !deliveryDate) { + return []; + } + + if (!rawSlots.length && deliveryDate) { + return [ + { + id: `slot-${deliveryDate}-${deliveryTime || "default"}`, + date: deliveryDate, + time: deliveryTime || "Половина дня", + }, + ]; + } + + return rawSlots.map((raw, index) => { + const parts = raw.split(","); + const datePart = parts[0]?.trim() || ""; + const timePart = parts.slice(1).join(",").trim() || ""; + + const parsedDate = datePart.replace(/[а-яё]+/gi, "").trim() + || deliveryDate + || ""; + + return { + id: `slot-${index}-${raw}`, + date: deliveryDate || parsedDate, + time: timePart || deliveryTime || raw, + }; + }); +}; + +export const ClientDeliveryPage = () => { + const { token } = useParams(); + const [searchParams] = useSearchParams(); + const [invitation, setInvitation] = React.useState(null); + const [loading, setLoading] = React.useState(Boolean(token)); + const [error, setError] = React.useState(""); + const [actionMessage, setActionMessage] = React.useState(""); + const [selectedSlotId, setSelectedSlotId] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + + const loadInvitation = async () => { + if (!token) { + setLoading(false); + setError("Не передан токен приглашения."); + return; + } + + setLoading(true); + setError(""); + + try { + const loadedInvitation = await fetchDeliveryInvitation(token); + if (!cancelled) { + setInvitation(loadedInvitation); + } + } catch (fetchError) { + if (!cancelled) { + setInvitation(null); + setError(fetchError instanceof Error ? fetchError.message : "Не удалось загрузить приглашение"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + loadInvitation(); + + return () => { + cancelled = true; + }; + }, [token]); + + const slots = React.useMemo( + () => groupSlotsFromInvitation(invitation), + [invitation], + ); + + const invitationState = invitation?.state || "awaiting_choice"; + + const handleConfirmChoice = React.useCallback( + async (deliveryTime) => { + if (!token) { + return; + } + + setActionMessage("Сохраняем выбор..."); + + try { + await confirmDeliveryChoice({ + token, + deliveryTime, + deliveryDate: searchParams.get("date") || invitation?.deliveryDate || undefined, + }); + const loadedInvitation = await fetchDeliveryInvitation(token); + setInvitation(loadedInvitation); + setActionMessage("Выбор сохранен, спасибо."); + } catch (confirmError) { + setActionMessage(""); + setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор"); + } + }, + [searchParams, token, invitation], + ); + + const handleSlotSelect = React.useCallback( + (slot) => { + setSelectedSlotId(slot.id); + handleConfirmChoice(slot.time); + }, + [handleConfirmChoice], + ); + + const handleRequestNewLink = React.useCallback(() => { + setActionMessage("Если ссылка больше не работает, логист передаст новую ссылку вручную."); + }, []); + + if (loading) { + return ( + <main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8"> + <div className="mx-auto flex w-full max-w-3xl flex-col gap-4"> + <Panel className="space-y-3 p-5 sm:p-6"> + <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Публичная ссылка</p> + <h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Загрузка приглашения</h1> + <p className="text-sm leading-6 text-[var(--color-text-muted)]">Подтягиваем актуальный статус заказа.</p> + </Panel> + </div> + </main> + ); + } + + if (error && !invitation) { + return ( + <main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8"> + <div className="mx-auto flex w-full max-w-3xl flex-col gap-4"> + <Panel className="space-y-3 p-5 sm:p-6"> + <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Публичная ссылка</p> + <h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Не удалось открыть заказ</h1> + <p className="text-sm leading-6 text-[var(--color-text-muted)]">{error}</p> + </Panel> + </div> + </main> + ); + } + + const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); + + return ( + <main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8"> + <div className="mx-auto flex w-full max-w-3xl flex-col gap-4"> + <Panel className="space-y-3 p-5 sm:p-6"> + <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Публичная ссылка</p> + <h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1> + <p className="text-sm leading-6 text-[var(--color-text-muted)]"> + {isActiveState + ? "Вам предложены варианты доставки. Выберите удобную дату и время." + : "По этому заказу согласование доставки завершено или передано логисту."} + </p> + </Panel> + + {isActiveState && slots.length ? ( + <DeliverySlotsPicker + slots={slots} + onSelectSlot={handleSlotSelect} + selectedSlotId={selectedSlotId} + /> + ) : null} + + {isActiveState ? ( + <DeliveryChoiceFlow + invitation={invitation} + onConfirmChoice={handleConfirmChoice} + onRequestNewLink={handleRequestNewLink} + /> + ) : ( + <DeliveryStateNotice state={invitationState} /> + )} + + {actionMessage ? ( + <Panel className="p-5 text-sm leading-6 text-[var(--color-text-muted)] sm:p-6">{actionMessage}</Panel> + ) : null} + + {!loading && error && invitation ? <DeliveryStateNotice state="default" /> : null} + </div> + </main> + ); +}; \ No newline at end of file diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js new file mode 100644 index 0000000..f6ca351 --- /dev/null +++ b/src/services/deliveryInvitationApi.js @@ -0,0 +1,60 @@ +import { supabase, hasSupabaseConfig } from "../supabaseClient"; + +const invokeDeliveryFunction = async (functionName, body) => { + if (!hasSupabaseConfig || !supabase?.functions?.invoke) { + throw new Error("Supabase is not configured"); + } + + const { data, error } = await supabase.functions.invoke(functionName, { + body, + }); + + if (error) { + throw error; + } + + return data; +}; + +export const fetchDeliveryInvitation = async (token) => + (await invokeDeliveryFunction("get-delivery-invitation", { token })).invitation; + +export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) => + invokeDeliveryFunction("confirm-delivery-choice", { + token, + deliveryDate, + deliveryTime, + }); + +export const requestDeliveryLink = async ({ + orderId, + orderNumber, + customerName, + customerPhone, + customerMessenger, + availableSlots, +}) => + invokeDeliveryFunction("create-delivery-invitation", { + orderId, + orderNumber, + customerName, + customerPhone, + customerMessenger, + availableSlots, + }); + +export const transferDeliveryToLogistics = async ({ orderId, reason, note, targetStatus }) => + invokeDeliveryFunction("transfer-to-logistics", { + orderId, + reason, + note, + targetStatus, + }); + +export const reportDeliveryResult = async ({ orderId, result, note, payload }) => + invokeDeliveryFunction("report-delivery-result", { + orderId, + result, + note, + payload, + }); diff --git a/src/services/deliveryInvitationApi.test.js b/src/services/deliveryInvitationApi.test.js new file mode 100644 index 0000000..3e95dd7 --- /dev/null +++ b/src/services/deliveryInvitationApi.test.js @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invoke } = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock("../supabaseClient", () => ({ + hasSupabaseConfig: true, + supabase: { + functions: { + invoke, + }, + }, +})); + +import { + confirmDeliveryChoice, + fetchDeliveryInvitation, + reportDeliveryResult, + requestDeliveryLink, + transferDeliveryToLogistics, +} from "./deliveryInvitationApi"; + +describe("deliveryInvitationApi", () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it("loads a delivery invitation by token", async () => { + invoke.mockResolvedValueOnce({ + data: { + ok: true, + invitation: { + orderId: "order-1", + token: "token-1", + }, + }, + error: null, + }); + + await expect(fetchDeliveryInvitation("token-1")).resolves.toEqual({ + orderId: "order-1", + token: "token-1", + }); + + expect(invoke).toHaveBeenCalledWith("get-delivery-invitation", { + body: { + token: "token-1", + }, + }); + }); + + it("confirms a delivery choice with the chosen slot", async () => { + invoke.mockResolvedValueOnce({ + data: { + ok: true, + orderId: "order-1", + }, + error: null, + }); + + await expect( + confirmDeliveryChoice({ + token: "token-1", + deliveryDate: "2026-04-01", + deliveryTime: "Первая половина дня", + }), + ).resolves.toEqual({ + ok: true, + orderId: "order-1", + }); + + expect(invoke).toHaveBeenCalledWith("confirm-delivery-choice", { + body: { + token: "token-1", + deliveryDate: "2026-04-01", + deliveryTime: "Первая половина дня", + }, + }); + }); + + it("creates a delivery invitation from order data", async () => { + invoke.mockResolvedValueOnce({ + data: { + ok: true, + invitation: { + orderId: "order-1", + }, + }, + error: null, + }); + + await expect( + requestDeliveryLink({ + orderId: "order-1", + orderNumber: "CD-240031", + customerName: "Мария Волкова", + availableSlots: ["Первая половина дня"], + }), + ).resolves.toEqual({ + ok: true, + invitation: { + orderId: "order-1", + }, + }); + + expect(invoke).toHaveBeenCalledWith("create-delivery-invitation", { + body: { + orderId: "order-1", + orderNumber: "CD-240031", + customerName: "Мария Волкова", + availableSlots: ["Первая половина дня"], + }, + }); + }); + + it("transfers the order to logistics", async () => { + invoke.mockResolvedValueOnce({ + data: { ok: true }, + error: null, + }); + + await expect( + transferDeliveryToLogistics({ + orderId: "order-1", + reason: "no_response", + }), + ).resolves.toEqual({ ok: true }); + + expect(invoke).toHaveBeenCalledWith("transfer-to-logistics", { + body: { + orderId: "order-1", + reason: "no_response", + }, + }); + }); + + it("reports delivery result", async () => { + invoke.mockResolvedValueOnce({ + data: { ok: true }, + error: null, + }); + + await expect( + reportDeliveryResult({ + orderId: "order-1", + result: "delivered", + note: "Передано клиенту", + }), + ).resolves.toEqual({ ok: true }); + + expect(invoke).toHaveBeenCalledWith("report-delivery-result", { + body: { + orderId: "order-1", + result: "delivered", + note: "Передано клиенту", + }, + }); + }); +}); diff --git a/src/services/deliverySetViews.js b/src/services/deliverySetViews.js new file mode 100644 index 0000000..023415e --- /dev/null +++ b/src/services/deliverySetViews.js @@ -0,0 +1,168 @@ +import { DELIVERY_SET_STATUSES } from "../constants/deliveryWorkflow"; + +const NORMALIZE_PHONE_RE = /[\s\-()]/g; + +const normalizePhone = (phone) => (phone || "").replace(NORMALIZE_PHONE_RE, "").toLowerCase(); + +const normalizeName = (name) => (name || "").trim().toLowerCase(); + +const computeFallbackSetKey = (order) => { + if (order.deliverySetKey) { + return order.deliverySetKey; + } + + const phone = normalizePhone( + order.sourceCustomerPhone || order.customer?.phone || "", + ); + const name = normalizeName( + order.sourceCustomerName || order.customer?.name || "", + ); + + if (!phone && !name) { + return `singleton-${order.id || order.orderNumber}`; + } + + return `${phone}::${name}`; +}; + +export const computeDeliverySetStatus = (orders) => { + if (!orders.length) { + return "approaching"; + } + + const allAccepted = orders.every( + (order) => order.sourceAcceptAt != null && order.sourceShipAt == null, + ); + const allShipped = orders.every( + (order) => order.sourceShipAt != null, + ); + + if (allShipped) { + return "completed"; + } + + if (allAccepted) { + return "ready_to_launch"; + } + + return "approaching"; +}; + +export const computeDeliverySetReadyAt = (orders) => { + if (!orders.length) { + return null; + } + + if (!orders.every((order) => order.sourceAcceptAt != null)) { + return null; + } + + const timestamps = orders + .map((order) => order.sourceAcceptAt) + .filter(Boolean) + .sort(); + + return timestamps.length ? timestamps[timestamps.length - 1] : null; +}; + +export const computeDeliveryReadyReason = (orders) => { + if (!orders.length) { + return "empty_set"; + } + + const allAccepted = orders.every( + (order) => order.sourceAcceptAt != null && order.sourceShipAt == null, + ); + + if (allAccepted) { + return "all_accepted"; + } + + return "waiting_for_acceptance"; +}; + +const getSourceFieldSummary = (order) => ({ + sourceOrderNumber: order.sourceOrderNumber || null, + sourceOrderDate: order.sourceOrderDate || null, + sourceCustomerName: order.sourceCustomerName || order.customer?.name || null, + sourceCustomerPhone: order.sourceCustomerPhone || order.customer?.phone || null, + sourceCustomerEmail: order.sourceCustomerEmail || null, + sourceCustomerCity: order.sourceCustomerCity || order.customer?.city || null, + sourceTotalSum: order.sourceTotalSum ?? null, + sourcePaidAt: order.sourcePaidAt || null, + sourceGateway: order.sourceGateway || null, + sourceAssociatedBillsText: order.sourceAssociatedBillsText || null, + sourceProductionAt: order.sourceProductionAt || null, + sourceSawAt: order.sourceSawAt || null, + sourceGlueAt: order.sourceGlueAt || null, + sourceHGlueAt: order.sourceHGlueAt || null, + sourceCurveAt: order.sourceCurveAt || null, + sourceAcceptAt: order.sourceAcceptAt || null, + sourceShipAt: order.sourceShipAt || null, + sourceSmsLegacyAt: order.sourceSmsLegacyAt || null, +}); + +export const groupOrdersIntoDeliverySets = (orders) => { + const setMap = new Map(); + + for (const order of orders) { + const key = computeFallbackSetKey(order); + + if (!setMap.has(key)) { + setMap.set(key, []); + } + + setMap.get(key).push(order); + } + + return Array.from(setMap.entries()).map(([key, setOrders]) => { + const firstName = setOrders[0]; + const computedStatus = computeDeliverySetStatus(setOrders); + const computedReadyAt = computeDeliverySetReadyAt(setOrders); + const computedReason = computeDeliveryReadyReason(setOrders); + + return { + key, + name: firstName.deliverySetName || firstName.sourceCustomerName || firstName.customer?.name || key, + status: firstName.deliverySetStatus || computedStatus, + readyAt: firstName.deliverySetReadyAt || computedReadyAt, + readyReason: firstName.deliveryReadyReason || computedReason, + sourceCustomerName: firstName.sourceCustomerName || firstName.customer?.name || null, + sourceCustomerPhone: firstName.sourceCustomerPhone || firstName.customer?.phone || null, + sourceCustomerCity: firstName.sourceCustomerCity || firstName.customer?.city || null, + orders: setOrders.map((order) => ({ + ...order, + sourceFieldSummary: getSourceFieldSummary(order), + })), + orderCount: setOrders.length, + linkedBillTexts: setOrders + .map((o) => o.sourceAssociatedBillsText) + .filter(Boolean) + .join("; ") || null, + }; + }).sort((a, b) => { + const statusPriority = DELIVERY_SET_STATUSES.indexOf(a.status) - DELIVERY_SET_STATUSES.indexOf(b.status); + if (statusPriority !== 0) { + return statusPriority; + } + + return (b.readyAt || "").localeCompare(a.readyAt || ""); + }); +}; + +export const getDeliverySetBucket = (status) => { + if (DELIVERY_SET_STATUSES.includes(status)) { + return status; + } + + return "approaching"; +}; + +export const DELIVERY_SET_BUCKET_LABELS = { + approaching: "На подходе", + ready_to_launch: "Готово к запуску", + awaiting_client: "Ожидает клиента", + manual_work: "Нужна ручная работа", + agreed: "Согласовано", + completed: "Завершено", +}; \ No newline at end of file diff --git a/src/services/deliverySetViews.test.js b/src/services/deliverySetViews.test.js new file mode 100644 index 0000000..d3ccd67 --- /dev/null +++ b/src/services/deliverySetViews.test.js @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + computeDeliverySetStatus, + computeDeliverySetReadyAt, + computeDeliveryReadyReason, + groupOrdersIntoDeliverySets, + getDeliverySetBucket, + DELIVERY_SET_BUCKET_LABELS, +} from "./deliverySetViews"; + +const makeOrder = (overrides = {}) => ({ + id: overrides.id || "order-1", + orderNumber: overrides.orderNumber || "CD-240031", + customer: overrides.customer || { name: "Тест", phone: "+7 978 000-00-00" }, + status: overrides.status || "Готов к отгрузке", + sourceAcceptAt: overrides.sourceAcceptAt || "2026-04-12T14:00:00Z", + sourceShipAt: overrides.sourceShipAt || null, + sourceOrderNumber: overrides.sourceOrderNumber || "УН-00031", + sourceCustomerName: overrides.sourceCustomerName || "Тест", + sourceCustomerPhone: overrides.sourceCustomerPhone || "+7 978 000-00-00", + sourceCustomerCity: overrides.sourceCustomerCity || "Симферополь", + deliverySetKey: overrides.deliverySetKey || null, + deliverySetName: overrides.deliverySetName || null, + deliverySetStatus: overrides.deliverySetStatus || null, + deliverySetReadyAt: overrides.deliverySetReadyAt || null, + deliveryReadyReason: overrides.deliveryReadyReason || null, + sourceAssociatedBillsText: overrides.sourceAssociatedBillsText || null, + sourceSmsLegacyAt: overrides.sourceSmsLegacyAt || null, + ...overrides, +}); + +describe("computeDeliverySetStatus", () => { + it("returns approaching when some orders are not accepted yet", () => { + const orders = [ + makeOrder({ sourceAcceptAt: "2026-04-12T14:00:00Z", sourceShipAt: null }), + makeOrder({ sourceAcceptAt: null, sourceShipAt: null, id: "order-2" }), + ]; + expect(computeDeliverySetStatus(orders)).toBe("approaching"); + }); + + it("returns ready_to_launch when all orders are accepted and none shipped", () => { + const orders = [ + makeOrder({ sourceAcceptAt: "2026-04-12T14:00:00Z", sourceShipAt: null }), + makeOrder({ sourceAcceptAt: "2026-04-12T16:00:00Z", sourceShipAt: null, id: "order-2" }), + ]; + expect(computeDeliverySetStatus(orders)).toBe("ready_to_launch"); + }); + + it("returns completed when all orders are shipped", () => { + const orders = [ + makeOrder({ sourceAcceptAt: "2026-04-12T14:00:00Z", sourceShipAt: "2026-04-13T08:00:00Z" }), + makeOrder({ sourceAcceptAt: "2026-04-12T16:00:00Z", sourceShipAt: "2026-04-13T09:00:00Z", id: "order-2" }), + ]; + expect(computeDeliverySetStatus(orders)).toBe("completed"); + }); + + it("ignores sourceSmsLegacyAt for readiness", () => { + const orders = [ + makeOrder({ sourceSmsLegacyAt: "2026-04-12T15:00:00Z", sourceAcceptAt: null, sourceShipAt: null }), + ]; + expect(computeDeliverySetStatus(orders)).toBe("approaching"); + }); +}); + +describe("computeDeliverySetReadyAt", () => { + it("returns the latest accept_at timestamp when all are accepted", () => { + const orders = [ + makeOrder({ sourceAcceptAt: "2026-04-12T14:00:00Z" }), + makeOrder({ sourceAcceptAt: "2026-04-12T16:00:00Z", id: "order-2" }), + ]; + expect(computeDeliverySetReadyAt(orders)).toBe("2026-04-12T16:00:00Z"); + }); + + it("returns null when some orders are not accepted", () => { + const orders = [ + makeOrder({ sourceAcceptAt: "2026-04-12T14:00:00Z" }), + makeOrder({ sourceAcceptAt: null, id: "order-2" }), + ]; + expect(computeDeliverySetReadyAt(orders)).toBeNull(); + }); +}); + +describe("computeDeliveryReadyReason", () => { + it("returns all_accepted when set is ready to launch", () => { + const orders = [ + makeOrder({ sourceAcceptAt: "2026-04-12T14:00:00Z", sourceShipAt: null }), + ]; + expect(computeDeliveryReadyReason(orders)).toBe("all_accepted"); + }); + + it("returns waiting_for_acceptance when some lack accept", () => { + const orders = [ + makeOrder({ sourceAcceptAt: "2026-04-12T14:00:00Z", sourceShipAt: null }), + makeOrder({ sourceAcceptAt: null, sourceShipAt: null, id: "order-2" }), + ]; + expect(computeDeliveryReadyReason(orders)).toBe("waiting_for_acceptance"); + }); +}); + +describe("groupOrdersIntoDeliverySets", () => { + it("groups by explicit deliverySetKey", () => { + const orders = [ + makeOrder({ deliverySetKey: "set-a", orderNumber: "CD-1" }), + makeOrder({ deliverySetKey: "set-a", orderNumber: "CD-2", id: "order-2" }), + makeOrder({ deliverySetKey: "set-b", orderNumber: "CD-3", id: "order-3" }), + ]; + const sets = groupOrdersIntoDeliverySets(orders); + expect(sets).toHaveLength(2); + const setA = sets.find((s) => s.key === "set-a"); + expect(setA.orders).toHaveLength(2); + }); + + it("falls back to normalized phone+name grouping when no deliverySetKey", () => { + const orders = [ + makeOrder({ deliverySetKey: null, sourceCustomerPhone: "+7 978 000-12-31", sourceCustomerName: "Волкова М.А.", orderNumber: "CD-1" }), + makeOrder({ deliverySetKey: null, sourceCustomerPhone: "+7 978 000-12-31", sourceCustomerName: "Волкова М.А.", orderNumber: "CD-2", id: "order-2" }), + makeOrder({ deliverySetKey: null, sourceCustomerPhone: "+7 978 000-99-99", sourceCustomerName: "Другой", orderNumber: "CD-3", id: "order-3" }), + ]; + const sets = groupOrdersIntoDeliverySets(orders); + expect(sets).toHaveLength(2); + }); + + it("computes status and readyAt for each set", () => { + const orders = [ + makeOrder({ deliverySetKey: "set-a", sourceAcceptAt: "2026-04-12T14:00:00Z", sourceShipAt: null }), + makeOrder({ deliverySetKey: "set-a", sourceAcceptAt: "2026-04-12T16:00:00Z", sourceShipAt: null, id: "order-2" }), + ]; + const sets = groupOrdersIntoDeliverySets(orders); + expect(sets[0].status).toBe("ready_to_launch"); + expect(sets[0].readyAt).toBe("2026-04-12T16:00:00Z"); + }); +}); + +describe("getDeliverySetBucket", () => { + it("maps approaching status to correct bucket", () => { + expect(getDeliverySetBucket("approaching")).toBe("approaching"); + }); + + it("maps ready_to_launch status to correct bucket", () => { + expect(getDeliverySetBucket("ready_to_launch")).toBe("ready_to_launch"); + }); + + it("maps awaiting_client status to correct bucket", () => { + expect(getDeliverySetBucket("awaiting_client")).toBe("awaiting_client"); + }); + + it("maps manual_work status to correct bucket", () => { + expect(getDeliverySetBucket("manual_work")).toBe("manual_work"); + }); + + it("maps agreed status to correct bucket", () => { + expect(getDeliverySetBucket("agreed")).toBe("agreed"); + }); + + it("maps completed status to correct bucket", () => { + expect(getDeliverySetBucket("completed")).toBe("completed"); + }); +}); + +describe("DELIVERY_SET_BUCKET_LABELS", () => { + it("has all bucket labels in Russian", () => { + expect(DELIVERY_SET_BUCKET_LABELS.approaching).toBe("На подходе"); + expect(DELIVERY_SET_BUCKET_LABELS.ready_to_launch).toBe("Готово к запуску"); + expect(DELIVERY_SET_BUCKET_LABELS.awaiting_client).toBe("Ожидает клиента"); + expect(DELIVERY_SET_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа"); + expect(DELIVERY_SET_BUCKET_LABELS.agreed).toBe("Согласовано"); + expect(DELIVERY_SET_BUCKET_LABELS.completed).toBe("Завершено"); + }); +}); \ No newline at end of file diff --git a/src/services/orderViews.test.js b/src/services/orderViews.test.js index d5ba3e0..1d4be99 100644 --- a/src/services/orderViews.test.js +++ b/src/services/orderViews.test.js @@ -90,7 +90,7 @@ describe("orderViews", () => { }); expect(withoutCompleted.map((column) => column.key)).toEqual([ - "manager", + "import", "production", "logistics", "delivery", @@ -112,9 +112,9 @@ describe("orderViews", () => { }); expect(columns[0].items[0]).toMatchObject({ - ownerRole: "manager", - stageKey: "manager", - stageLabel: "Менеджер", + ownerRole: "logistician", + stageKey: "import", + stageLabel: "Импорт из 1С", agingState: "normal", }); expect(columns.map((column) => column.key)).toContain("Ожидает согласования доставки"); @@ -152,6 +152,7 @@ describe("orderViews", () => { expect( filterKanbanColumnsByStage(statusColumns, "logistics").map((column) => column.key), ).toEqual([ + "Готов к отгрузке", "Ожидает ответа клиента", "Ожидает согласования доставки", "Доставка согласована", diff --git a/src/services/supabase/orderRepository.js b/src/services/supabase/orderRepository.js index c07f561..e2602de 100644 --- a/src/services/supabase/orderRepository.js +++ b/src/services/supabase/orderRepository.js @@ -5,7 +5,7 @@ const CHANNEL_CODES = { "телеграм": "telegram", "вконтакте": "vk", "макс": "messenger_max", - "max": "messenger_max", + max: "messenger_max", "смс": "sms", "эл. почта": "email", "эл почта": "email", @@ -21,32 +21,209 @@ const requireSupabase = () => { if (!hasSupabaseConfig || !supabase) { throw new Error("Supabase не сконфигурирован"); } + return supabase; }; -export const buildOrderPayload = (order) => ({ - id: order.id, - order_number: order.orderNumber, - customer: order.customer, - status: order.status, - delivery_agreement_status: order.deliveryAgreementStatus || "Не начато", - manager_id: order.managerId, - logistician_id: order.logisticianIds[0] || null, - assigned_driver_id: order.assignedDriverId || null, +const mapHistoryRow = (row) => ({ + id: row.id, + action: row.action, + oldStatus: row.old_status, + newStatus: row.new_status, + userId: row.user_id, + userName: row.user_name || null, + metadata: row.metadata || {}, + at: row.created_at, }); +const mapDeliverySlotRow = (row) => ({ + id: row.id, + date: row.delivery_date, + time: row.delivery_time, + logisticianId: row.logistician_id || null, + logisticianName: row.logistician_name || null, + status: row.status, + createdAt: row.created_at, + selectedByClientAt: row.selected_by_client_at || null, +}); + +const mapChatMessageRow = (row) => ({ + id: row.id, + sender: row.sender_type, + senderName: row.sender_name || null, + channel: row.channel, + text: row.text, + externalMessageId: row.external_message_id || null, + payload: row.payload || {}, + sentAt: row.created_at, +}); + +const getCustomerBlob = (row) => row.customer || {}; + +export const mapOrderRowToOrder = (row) => { + if (!row) { + return null; + } + + const customer = getCustomerBlob(row); + const history = Array.isArray(row.order_history) ? row.order_history.map(mapHistoryRow) : []; + const deliverySlots = Array.isArray(row.delivery_slots) + ? row.delivery_slots.map(mapDeliverySlotRow) + : []; + const chatMessages = Array.isArray(row.chat_messages) + ? row.chat_messages.map(mapChatMessageRow) + : []; + const logisticianIdsFromAssignments = Array.isArray(row.order_logisticians) + ? row.order_logisticians.map((item) => item.logistician_id).filter(Boolean) + : []; + const logisticianIds = Array.from( + new Set([row.logistician_id, ...logisticianIdsFromAssignments].filter(Boolean)), + ); + + return { + id: row.id, + orderNumber: row.order_number, + customer: { + name: customer.name || "Без имени", + phone: customer.phone || "", + messenger: customer.messenger || "Телеграм", + address: customer.address || "", + city: customer.city || "", + items: Array.isArray(customer.items) ? customer.items : [], + comments: Array.isArray(customer.comments) ? customer.comments : [], + tags: Array.isArray(customer.tags) ? customer.tags : [], + orderNotes: Array.isArray(customer.orderNotes) ? customer.orderNotes : [], + internalMessages: Array.isArray(customer.internalMessages) ? customer.internalMessages : [], + scheduledDelivery: customer.scheduledDelivery || null, + deliveryDate: customer.deliveryDate || null, + deliveryTime: customer.deliveryTime || null, + exception: customer.exception || null, + }, + status: row.status, + deliveryAgreementStatus: row.delivery_agreement_status || "Не начато", + managerId: row.manager_id || null, + logisticianIds, + assignedDriverId: row.assigned_driver_id || null, + driverRouteOrder: customer.driverRouteOrder ?? null, + readyForDeliveryAt: row.ready_for_delivery_at || null, + deliveryFlowStartedAt: row.delivery_flow_started_at || null, + deliveryFlowSource: row.delivery_flow_source || null, + sourceOrderNumber: row.source_order_number || null, + sourceOrderDate: row.source_order_date || null, + sourceCustomerName: row.source_customer_name || null, + sourceCustomerPhone: row.source_customer_phone || null, + sourceCustomerEmail: row.source_customer_email || null, + sourceCustomerCity: row.source_customer_city || null, + sourceTotalSum: row.source_total_sum ?? null, + sourcePaidAt: row.source_paid_at || null, + sourceGateway: row.source_gateway || null, + sourceAssociatedBillsText: row.source_associated_bills_text || null, + sourceProductionAt: row.source_production_at || null, + sourceSawAt: row.source_saw_at || null, + sourceGlueAt: row.source_glue_at || null, + sourceHGlueAt: row.source_h_glue_at || null, + sourceCurveAt: row.source_curve_at || null, + sourceAcceptAt: row.source_accept_at || null, + sourceShipAt: row.source_ship_at || null, + sourceSmsLegacyAt: row.source_sms_legacy_at || null, + sourcePayload: row.source_payload || null, + deliverySetKey: row.delivery_set_key || null, + deliverySetName: row.delivery_set_name || null, + deliverySetStatus: row.delivery_set_status || null, + deliverySetReadyAt: row.delivery_set_ready_at || null, + deliveryReadyReason: row.delivery_ready_reason || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + scheduledDelivery: + customer.scheduledDelivery || customer.deliveryDate || row.ready_for_delivery_at || null, + items: Array.isArray(customer.items) ? customer.items : [], + comments: Array.isArray(customer.comments) ? customer.comments : [], + tags: Array.isArray(customer.tags) ? customer.tags : [], + orderNotes: Array.isArray(customer.orderNotes) ? customer.orderNotes : [], + internalMessages: Array.isArray(customer.internalMessages) ? customer.internalMessages : [], + history, + chatMessages, + deliverySlots, + exception: customer.exception || null, + }; +}; + +export const enrichOrdersWithUsers = (orders, users = []) => { + const usersById = new Map(users.map((user) => [user.id, user])); + + return orders.map((order) => ({ + ...order, + history: order.history.map((entry) => ({ + ...entry, + userName: entry.userName || usersById.get(entry.userId)?.name || entry.userName || "Система", + })), + deliverySlots: order.deliverySlots.map((slot) => ({ + ...slot, + logisticianName: + slot.logisticianName || usersById.get(slot.logisticianId)?.name || slot.logisticianName || null, + })), + })); +}; + +export const buildOrderPayload = (order) => { + const customer = order.customer || {}; + + return { + id: order.id, + order_number: order.orderNumber, + customer: { + name: customer.name || "", + phone: customer.phone || "", + messenger: customer.messenger || "Телеграм", + address: customer.address || "", + city: customer.city || "", + items: Array.isArray(order.items) ? order.items : Array.isArray(customer.items) ? customer.items : [], + comments: Array.isArray(order.comments) + ? order.comments + : Array.isArray(customer.comments) + ? customer.comments + : [], + tags: Array.isArray(order.tags) ? order.tags : Array.isArray(customer.tags) ? customer.tags : [], + orderNotes: Array.isArray(order.orderNotes) + ? order.orderNotes + : Array.isArray(customer.orderNotes) + ? customer.orderNotes + : [], + internalMessages: Array.isArray(order.internalMessages) + ? order.internalMessages + : Array.isArray(customer.internalMessages) + ? customer.internalMessages + : [], + scheduledDelivery: order.scheduledDelivery || customer.scheduledDelivery || null, + deliveryDate: customer.deliveryDate || null, + deliveryTime: customer.deliveryTime || null, + exception: order.exception || customer.exception || null, + driverRouteOrder: order.driverRouteOrder ?? customer.driverRouteOrder ?? null, + }, + status: order.status, + delivery_agreement_status: order.deliveryAgreementStatus || "Не начато", + manager_id: order.managerId || null, + logistician_id: order.logisticianIds?.[0] || null, + assigned_driver_id: order.assignedDriverId || null, + ready_for_delivery_at: order.readyForDeliveryAt || null, + delivery_flow_started_at: order.deliveryFlowStartedAt || null, + delivery_flow_source: order.deliveryFlowSource || null, + }; +}; + export const fetchOrders = async () => { return safeSupabaseCall(async () => { const client = requireSupabase(); const { data, error } = await client .from("orders") - .select("*, order_history(*), delivery_slots(*), chat_messages(*)") + .select("*, order_history(*), delivery_slots(*), chat_messages(*), order_logisticians(*)") .order("updated_at", { ascending: false }); if (error) { throw error; } - return data; + + return (data || []).map(mapOrderRowToOrder).filter(Boolean); }, "Ошибка загрузки заказов"); }; @@ -67,8 +244,7 @@ export const saveChatMessage = async ({ orderId, message }) => { return safeSupabaseCall(async () => { const client = requireSupabase(); const normalizedChannel = - CHANNEL_CODES[message.channel.toLowerCase()] || - message.channel.toLowerCase().replaceAll(" ", "_"); + CHANNEL_CODES[message.channel.toLowerCase()] || message.channel.toLowerCase().replace(/\s+/g, "_"); const { data, error } = await client .from("chat_messages") .insert({ diff --git a/src/services/supabase/orderRepository.test.js b/src/services/supabase/orderRepository.test.js index 668aec4..83ece5c 100644 --- a/src/services/supabase/orderRepository.test.js +++ b/src/services/supabase/orderRepository.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { demoOrders } from "../../data/mockAppData"; -import { buildOrderPayload } from "./orderRepository"; +import { buildOrderPayload, enrichOrdersWithUsers, mapOrderRowToOrder } from "./orderRepository"; describe("orderRepository payloads", () => { it("maps demo order fields to Supabase payload", () => { @@ -12,4 +12,126 @@ describe("orderRepository payloads", () => { expect(payload.assigned_driver_id).toBe("u-driver"); expect(payload.logistician_id).toBe("u-logistics-2"); }); + + it("maps a Supabase order row into the app shape", () => { + const order = mapOrderRowToOrder({ + id: "order-id", + order_number: "CD-240100", + customer: { + name: "Покупатель", + phone: "+7 978 000-00-00", + messenger: "Телеграм", + address: "Симферополь, ул. Ленина, 1", + city: "Симферополь", + items: ["Позиция | 2 шт"], + comments: ["Комментарий"], + tags: ["tag"], + orderNotes: [{ id: "note-1", authorName: "Админ", text: "Заметка", createdAt: "2026-04-13T00:00:00Z" }], + internalMessages: [{ id: "internal-1", senderName: "Команда", text: "Внутренний", sentAt: "2026-04-13T00:00:00Z" }], + scheduledDelivery: "2026-04-14T08:00:00Z", + }, + status: "Ожидает ответа клиента", + delivery_agreement_status: "Отправлено клиенту", + manager_id: "manager-id", + logistician_id: "logistician-id", + assigned_driver_id: null, + ready_for_delivery_at: "2026-04-13T08:00:00Z", + delivery_flow_started_at: "2026-04-13T09:00:00Z", + delivery_flow_source: "1c_xml", + source_order_number: "УН-000100", + source_order_date: "2026-04-10", + source_customer_name: "Покупатель Иван", + source_customer_phone: "+797800000000", + source_customer_email: "buyer@example.com", + source_customer_city: "Симферополь", + source_total_sum: 150000.00, + source_paid_at: "2026-04-11T10:00:00Z", + source_gateway: "bank_transfer", + source_associated_bills_text: "Счет №100 от 10.04.2026", + source_production_at: "2026-04-11T09:00:00Z", + source_saw_at: "2026-04-12T08:00:00Z", + source_glue_at: null, + source_h_glue_at: null, + source_curve_at: null, + source_accept_at: "2026-04-12T16:00:00Z", + source_ship_at: null, + source_payload: { source: "1c_xml" }, + source_sms_legacy_at: "2026-04-12T17:00:00Z", + delivery_set_key: "buyer-simferopol-2026-04", + delivery_set_name: "Покупатель И. — Симферополь, апрель 2026", + delivery_set_status: "ready_to_launch", + delivery_set_ready_at: "2026-04-12T16:00:00Z", + delivery_ready_reason: "all_accepted", + created_at: "2026-04-13T08:00:00Z", + updated_at: "2026-04-13T09:00:00Z", + order_history: [ + { + id: "history-1", + action: "Создан заказ", + old_status: null, + new_status: "Новый", + user_id: "manager-id", + metadata: { source: "seed" }, + created_at: "2026-04-13T08:00:00Z", + }, + ], + delivery_slots: [ + { + id: "slot-1", + delivery_date: "2026-04-14", + delivery_time: "Первая половина дня", + logistician_id: "logistician-id", + status: "pending_confirmation", + created_at: "2026-04-13T09:05:00Z", + }, + ], + chat_messages: [ + { + id: "chat-1", + sender_type: "bot", + sender_name: "Система", + channel: "sms", + text: "Готово", + external_message_id: null, + payload: { source: "seed" }, + created_at: "2026-04-13T09:10:00Z", + }, + ], + order_logisticians: [{ logistician_id: "logistician-id" }], + }); + + expect(order.orderNumber).toBe("CD-240100"); + expect(order.customer.items).toEqual(["Позиция | 2 шт"]); + expect(order.history[0].action).toBe("Создан заказ"); + expect(order.deliverySlots[0].date).toBe("2026-04-14"); + expect(order.chatMessages[0].sender).toBe("bot"); + expect(order.logisticianIds).toEqual(["logistician-id"]); + expect(order.sourceOrderNumber).toBe("УН-000100"); + expect(order.sourceCustomerName).toBe("Покупатель Иван"); + expect(order.sourceAcceptAt).toBe("2026-04-12T16:00:00Z"); + expect(order.sourceShipAt).toBeNull(); + expect(order.sourceSmsLegacyAt).toBe("2026-04-12T17:00:00Z"); + expect(order.deliverySetKey).toBe("buyer-simferopol-2026-04"); + expect(order.deliverySetStatus).toBe("ready_to_launch"); + expect(order.deliverySetReadyAt).toBe("2026-04-12T16:00:00Z"); + }); + + it("enriches order history and delivery slots with live users", () => { + const enriched = enrichOrdersWithUsers( + [ + { + id: "order-id", + history: [{ id: "history-1", userId: "user-1", action: "Создан заказ", at: "2026-04-13T00:00:00Z" }], + deliverySlots: [{ id: "slot-1", logisticianId: "user-2", date: "2026-04-14", time: "Первая половина дня" }], + }, + ], + [ + { id: "user-1", name: "Елена Родович" }, + { id: "user-2", name: "Михаил Кучер" }, + ], + ); + + expect(enriched[0].history[0].userName).toBe("Елена Родович"); + expect(enriched[0].deliverySlots[0].logisticianName).toBe("Михаил Кучер"); + }); }); diff --git a/src/services/supabase/userRepository.js b/src/services/supabase/userRepository.js new file mode 100644 index 0000000..9231836 --- /dev/null +++ b/src/services/supabase/userRepository.js @@ -0,0 +1,43 @@ +import { safeSupabaseCall } from "../safeSupabaseCall"; +import { hasSupabaseConfig, supabase } from "../../supabaseClient"; + +const requireSupabase = () => { + if (!hasSupabaseConfig || !supabase) { + throw new Error("Supabase не сконфигурирован"); + } + + return supabase; +}; + +export const mapUserRowToAppUser = (row) => { + if (!row) { + return null; + } + + const roleInfo = Array.isArray(row.role_info) ? row.role_info[0] : row.role_info; + + return { + id: row.id, + email: row.email, + name: row.name, + role: roleInfo?.name || "manager", + lastLogin: row.last_login, + botBindings: row.bot_bindings || null, + }; +}; + +export const fetchUsers = async () => { + return safeSupabaseCall(async () => { + const client = requireSupabase(); + const { data, error } = await client + .from("users") + .select("id, email, name, last_login, role_info:roles(name)") + .order("name", { ascending: true }); + + if (error) { + throw error; + } + + return (data || []).map(mapUserRowToAppUser).filter(Boolean); + }, "Ошибка загрузки пользователей"); +}; diff --git a/src/services/supabase/userRepository.test.js b/src/services/supabase/userRepository.test.js new file mode 100644 index 0000000..9d7b639 --- /dev/null +++ b/src/services/supabase/userRepository.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { mapUserRowToAppUser } from "./userRepository"; + +describe("userRepository", () => { + it("maps Supabase user rows into app users", () => { + expect( + mapUserRowToAppUser({ + id: "user-1", + email: "skylanguage@yandex.ru", + name: "Елена Родович", + last_login: "2026-04-13T09:00:00Z", + role_info: [{ name: "admin" }], + }), + ).toEqual({ + id: "user-1", + email: "skylanguage@yandex.ru", + name: "Елена Родович", + role: "admin", + lastLogin: "2026-04-13T09:00:00Z", + botBindings: null, + }); + }); +}); diff --git a/src/utils/formatters.test.js b/src/utils/formatters.test.js new file mode 100644 index 0000000..6fafc6f --- /dev/null +++ b/src/utils/formatters.test.js @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { formatDate, formatDateTime } from "./formatters"; + +describe("formatters", () => { + it("returns a fallback for invalid datetime values", () => { + expect(formatDateTime("2026-03-18T010:00:00Z")).toBe("Не указано"); + expect(formatDateTime("not-a-date")).toBe("Не указано"); + }); + + it("returns a fallback for invalid date values", () => { + expect(formatDate("2026-03-18T010:00:00Z")).toBe("Не указано"); + expect(formatDate(null)).toBe("Не указано"); + }); +}); diff --git a/supabase/functions/_shared/delivery-invitations.test.ts b/supabase/functions/_shared/delivery-invitations.test.ts new file mode 100644 index 0000000..a258eb4 --- /dev/null +++ b/supabase/functions/_shared/delivery-invitations.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_AVAILABLE_SLOTS, + getClientInvitationStateFromOrderStatus, + getOrderUpdateForDeliveryInvitationAction, + normalizeAvailableSlots, +} from "./delivery-invitations"; + +describe("delivery invitation helpers", () => { + it("maps invitation creation to awaiting customer response", () => { + expect(getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation")).toEqual({ + status: "Ожидает ответа клиента", + deliveryAgreementStatus: "Отправлено клиенту", + }); + }); + + it("maps manual logistics transfer to the logistics handoff status", () => { + expect(getOrderUpdateForDeliveryInvitationAction("transfer_to_logistics")).toEqual({ + status: "Передан логисту", + deliveryAgreementStatus: "Нет ответа", + }); + }); + + it("derives public client state from the current order status", () => { + expect(getClientInvitationStateFromOrderStatus("Ожидает ответа клиента")).toBe("awaiting_choice"); + expect(getClientInvitationStateFromOrderStatus("Передан логисту")).toBe("transferred_to_logistics"); + expect(getClientInvitationStateFromOrderStatus("Платное хранение")).toBe("paid_storage"); + expect(getClientInvitationStateFromOrderStatus("Доставлен")).toBe("delivered"); + }); + + it("normalizes delivery slots and falls back to the default list", () => { + expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]); + expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS); + }); +}); diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts new file mode 100644 index 0000000..d8e4bf5 --- /dev/null +++ b/supabase/functions/_shared/delivery-invitations.ts @@ -0,0 +1,110 @@ +export type DeliveryInvitationAction = + | "create_delivery_invitation" + | "send_delivery_offer" + | "send_delivery_reminder" + | "request_new_link" + | "confirm_delivery_choice" + | "transfer_to_logistics" + | "mark_paid_storage" + | "mark_delivered"; + +export type DeliveryInvitationPublicState = + | "awaiting_choice" + | "opened" + | "reminder_sent" + | "transferred_to_logistics" + | "paid_storage" + | "delivered" + | "agreed" + | "default"; + +export const DEFAULT_AVAILABLE_SLOTS = ["Первая половина дня", "Вторая половина дня"]; + +export const getOrderUpdateForDeliveryInvitationAction = (action: DeliveryInvitationAction) => { + switch (action) { + case "create_delivery_invitation": + case "send_delivery_offer": + case "send_delivery_reminder": + case "request_new_link": + return { + status: "Ожидает ответа клиента", + deliveryAgreementStatus: "Отправлено клиенту", + }; + case "confirm_delivery_choice": + return { + status: "Доставка согласована", + deliveryAgreementStatus: "Подтверждено клиентом", + }; + case "transfer_to_logistics": + return { + status: "Передан логисту", + deliveryAgreementStatus: "Нет ответа", + }; + case "mark_paid_storage": + return { + status: "Платное хранение", + deliveryAgreementStatus: "Нет ответа", + }; + case "mark_delivered": + return { + status: "Доставлен", + deliveryAgreementStatus: "Подтверждено клиентом", + }; + default: + return null; + } +}; + +export const getClientInvitationStateFromOrderStatus = ( + status: string, +): DeliveryInvitationPublicState => { + switch (status) { + case "Ожидает ответа клиента": + return "awaiting_choice"; + case "Ожидает согласования доставки": + return "opened"; + case "Напоминание отправлено": + case "Переход отправлен": + return "reminder_sent"; + case "Передан логисту": + return "transferred_to_logistics"; + case "Платное хранение": + return "paid_storage"; + case "Доставлен": + return "delivered"; + case "Доставка согласована": + return "agreed"; + default: + return "default"; + } +}; + +export const isActiveInvitationState = (state: DeliveryInvitationPublicState) => + state === "awaiting_choice" || state === "opened" || state === "reminder_sent"; + +export const generateInvitationToken = () => crypto.randomUUID().replaceAll("-", ""); + +export const hashInvitationToken = async (token: string) => { + const bytes = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +export const normalizeAvailableSlots = (availableSlots?: string[] | null) => { + const slots = availableSlots?.map((slot) => slot.trim()).filter(Boolean) || []; + return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS]; +}; + +export const resolvePublicAppUrl = ( + request: Request, + fallbackEnv?: string, +) => { + const origin = request.headers.get("origin") || request.headers.get("referer") || ""; + const envValue = + fallbackEnv || + (typeof Deno !== "undefined" ? Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") : ""); + return (envValue || origin || "").replace(/\/$/, ""); +}; + +export const buildInvitationUrl = (baseUrl: string, token: string) => + `${baseUrl.replace(/\/$/, "")}/delivery/${token}`; diff --git a/supabase/functions/_shared/integration-events.ts b/supabase/functions/_shared/integration-events.ts new file mode 100644 index 0000000..8396882 --- /dev/null +++ b/supabase/functions/_shared/integration-events.ts @@ -0,0 +1,30 @@ +type IntegrationEventPayload = { + order_id?: string | null; + event_type: string; + direction?: "inbound" | "outbound" | "internal"; + source?: string; + status?: string; + payload?: Record<string, unknown>; + error_message?: string | null; +}; + +export const insertIntegrationEvent = async ( + supabase: { + from: (table: string) => { + insert: (payload: IntegrationEventPayload) => Promise<{ error: Error | null }>; + }; + }, + payload: IntegrationEventPayload, +) => { + const { error } = await supabase.from("integration_events").insert({ + direction: "internal", + source: "supabase-function", + status: "success", + payload: {}, + ...payload, + }); + + if (error) { + throw error; + } +}; diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts new file mode 100644 index 0000000..1c4d58c --- /dev/null +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -0,0 +1,197 @@ +import { + getOrderUpdateForDeliveryInvitationAction, + hashInvitationToken, + isActiveInvitationState, +} from "../_shared/delivery-invitations.ts"; +import { createServiceClient } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + try { + const body = (await request.json()) as { + token?: string; + deliveryDate?: string; + deliveryTime?: string; + }; + + if (!body.token) { + return new Response(JSON.stringify({ error: "token is required" }), { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + const tokenHash = await hashInvitationToken(body.token); + const supabase = createServiceClient(); + + const { data: invitation, error: invitationError } = await supabase + .from("delivery_invitations") + .select("*") + .eq("token_hash", tokenHash) + .single(); + + if (invitationError) { + if (invitationError.code === "PGRST116") { + return new Response( + JSON.stringify({ ok: false, error: "Invitation not found" }), + { + status: 404, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } + + throw invitationError; + } + + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", invitation.order_id) + .single(); + + if (orderError) { + throw orderError; + } + + if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) { + return new Response( + JSON.stringify({ + ok: false, + error: "Invitation is no longer active", + }), + { + status: 409, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } + + const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice"); + const deliveryDate = body.deliveryDate || new Date().toISOString().slice(0, 10); + const deliveryTime = body.deliveryTime || "Первая половина дня"; + + const { error: invitationUpdateError } = await supabase + .from("delivery_invitations") + .update({ + state: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + confirmed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + + if (invitationUpdateError) { + throw invitationUpdateError; + } + + const { error: orderUpdateError } = await supabase + .from("orders") + .update({ + status: orderUpdate?.status, + delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + }) + .eq("id", invitation.order_id); + + if (orderUpdateError) { + throw orderUpdateError; + } + + const { error: slotError } = await supabase.from("delivery_slots").insert({ + order_id: invitation.order_id, + delivery_date: deliveryDate, + delivery_time: deliveryTime, + logistician_id: null, + status: "confirmed_by_client", + }); + + if (slotError) { + throw slotError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: invitation.order_id, + action: "Подтверждение выбора доставки клиентом", + old_status: currentOrder.status, + new_status: orderUpdate?.status, + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + delivery_date: deliveryDate, + delivery_time: deliveryTime, + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: invitation.order_id, + event_type: "delivery_choice_confirmed", + direction: "inbound", + status: "success", + payload: { + delivery_date: deliveryDate, + delivery_time: deliveryTime, + }, + }); + + return new Response( + JSON.stringify({ + ok: true, + orderId: invitation.order_id, + status: orderUpdate?.status, + deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus, + }), + { + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } +}); diff --git a/supabase/functions/create-delivery-invitation/index.ts b/supabase/functions/create-delivery-invitation/index.ts new file mode 100644 index 0000000..f466a92 --- /dev/null +++ b/supabase/functions/create-delivery-invitation/index.ts @@ -0,0 +1,195 @@ +import { + buildInvitationUrl, + generateInvitationToken, + getOrderUpdateForDeliveryInvitationAction, + hashInvitationToken, + normalizeAvailableSlots, + resolvePublicAppUrl, +} from "../_shared/delivery-invitations.ts"; +import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + try { + const body = (await request.json()) as { + orderId?: string; + orderNumber?: string; + customerName?: string; + customerPhone?: string; + customerMessenger?: string; + availableSlots?: string[]; + }; + + if (!body.orderId) { + return json({ error: "orderId is required" }, 400); + } + + const token = generateInvitationToken(); + const tokenHash = await hashInvitationToken(token); + const supabase = createServiceClient(); + const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation"); + + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status, delivery_flow_started_at") + .eq("id", body.orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const { data: existingInvitation, error: existingInvitationError } = await supabase + .from("delivery_invitations") + .select("id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at") + .eq("order_id", body.orderId) + .maybeSingle(); + + if (existingInvitationError) { + throw existingInvitationError; + } + + if (currentOrder.delivery_flow_started_at || existingInvitation) { + return new Response( + JSON.stringify({ + ok: true, + alreadyStarted: true, + invitation: existingInvitation + ? { + orderId: body.orderId, + state: existingInvitation.state, + availableSlots: existingInvitation.available_slots || [], + orderNumber: existingInvitation.order_number || body.orderNumber || null, + customerName: existingInvitation.customer_name || body.customerName || null, + customerPhone: existingInvitation.customer_phone || body.customerPhone || null, + customerMessenger: existingInvitation.customer_messenger || body.customerMessenger || null, + } + : { + orderId: body.orderId, + state: "awaiting_choice", + }, + }), + { + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } + + const invitationPayload = { + order_id: body.orderId, + token_hash: tokenHash, + state: "awaiting_choice", + order_number: body.orderNumber || null, + customer_name: body.customerName || null, + customer_phone: body.customerPhone || null, + customer_messenger: body.customerMessenger || null, + available_slots: normalizeAvailableSlots(body.availableSlots), + sent_at: new Date().toISOString(), + }; + + const { error: invitationError } = await supabase.from("delivery_invitations").insert(invitationPayload); + + if (invitationError) { + throw invitationError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: orderUpdate?.status, + delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + ready_for_delivery_at: currentOrder.ready_for_delivery_at || new Date().toISOString(), + delivery_flow_started_at: new Date().toISOString(), + delivery_flow_source: body.source || "n8n", + }) + .eq("id", body.orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: body.orderId, + action: "Создание приглашения доставки", + old_status: currentOrder.status, + new_status: orderUpdate?.status, + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, + channel: channelFromProvider("telegram"), + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: body.orderId, + event_type: "delivery_invitation_created", + direction: "outbound", + status: "success", + payload: { + token_hash: tokenHash, + available_slots: invitationPayload.available_slots, + }, + }); + + const publicBaseUrl = resolvePublicAppUrl(request); + + return new Response( + JSON.stringify({ + ok: true, + invitation: { + orderId: body.orderId, + token, + url: buildInvitationUrl(publicBaseUrl, token), + state: "awaiting_choice", + availableSlots: invitationPayload.available_slots, + }, + }), + { + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } +}); diff --git a/supabase/functions/get-delivery-invitation/index.ts b/supabase/functions/get-delivery-invitation/index.ts new file mode 100644 index 0000000..cee6ef6 --- /dev/null +++ b/supabase/functions/get-delivery-invitation/index.ts @@ -0,0 +1,125 @@ +import { + getClientInvitationStateFromOrderStatus, + hashInvitationToken, + isActiveInvitationState, +} from "../_shared/delivery-invitations.ts"; +import { createServiceClient } from "../_shared/chatbot.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (request.method !== "GET") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + try { + const url = new URL(request.url); + const token = url.searchParams.get("token") || ""; + if (!token) { + return new Response(JSON.stringify({ error: "token is required" }), { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + const tokenHash = await hashInvitationToken(token); + const supabase = createServiceClient(); + + const { data: invitation, error: invitationError } = await supabase + .from("delivery_invitations") + .select("*") + .eq("token_hash", tokenHash) + .single(); + + if (invitationError) { + if (invitationError.code === "PGRST116") { + return new Response( + JSON.stringify({ ok: false, error: "Invitation not found" }), + { + status: 404, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } + + throw invitationError; + } + + const { data: order, error: orderError } = await supabase + .from("orders") + .select("id, order_number, status, delivery_agreement_status, customer") + .eq("id", invitation.order_id) + .single(); + + if (orderError) { + throw orderError; + } + + const publicState = getClientInvitationStateFromOrderStatus(order.status); + + if (isActiveInvitationState(publicState) && !invitation.opened_at) { + await supabase + .from("delivery_invitations") + .update({ + opened_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + } + + return new Response( + JSON.stringify({ + ok: true, + invitation: { + orderId: invitation.order_id, + state: publicState, + token: token, + orderNumber: order.order_number, + customerName: order.customer?.name || invitation.customer_name || null, + customerPhone: order.customer?.phone || invitation.customer_phone || null, + availableSlots: invitation.available_slots || [], + orderStatus: order.status, + deliveryAgreementStatus: order.delivery_agreement_status, + }, + }), + { + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } +}); diff --git a/supabase/functions/report-delivery-result/index.ts b/supabase/functions/report-delivery-result/index.ts new file mode 100644 index 0000000..96168d9 --- /dev/null +++ b/supabase/functions/report-delivery-result/index.ts @@ -0,0 +1,149 @@ +import { + getOrderUpdateForDeliveryInvitationAction, +} from "../_shared/delivery-invitations.ts"; +import { createServiceClient } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + try { + const body = (await request.json()) as { + orderId?: string; + result?: "delivered" | "problem"; + note?: string; + payload?: Record<string, unknown>; + }; + + if (!body.orderId) { + return new Response(JSON.stringify({ error: "orderId is required" }), { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + const supabase = createServiceClient(); + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", body.orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const isDelivered = body.result === "delivered"; + const action = isDelivered ? "mark_delivered" : "mark_paid_storage"; + const orderUpdate = getOrderUpdateForDeliveryInvitationAction(action); + const nextStatus = isDelivered ? orderUpdate?.status || "Доставлен" : "Проблема доставки"; + + const { error: invitationError } = await supabase + .from("delivery_invitations") + .update({ + state: isDelivered ? "delivered" : "paid_storage", + ...(isDelivered ? { delivered_at: new Date().toISOString() } : { paid_storage_at: new Date().toISOString() }), + }) + .eq("order_id", body.orderId); + + if (invitationError) { + throw invitationError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: nextStatus, + delivery_agreement_status: isDelivered + ? "Подтверждено клиентом" + : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", + }) + .eq("id", body.orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: body.orderId, + action: isDelivered ? "Подтверждение доставки" : "Фиксация проблемы доставки", + old_status: currentOrder.status, + new_status: isDelivered ? "Доставлен" : "Проблема доставки", + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: isDelivered + ? "Подтверждено клиентом" + : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", + payload: body.payload || {}, + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: body.orderId, + event_type: isDelivered ? "delivery_result_delivered" : "delivery_result_problem", + direction: "internal", + status: "success", + payload: { + result: body.result || null, + note: body.note || null, + payload: body.payload || {}, + }, + }); + + return new Response( + JSON.stringify({ + ok: true, + orderId: body.orderId, + status: nextStatus, + deliveryAgreementStatus: isDelivered + ? "Подтверждено клиентом" + : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", + workflowStatus: nextStatus, + }), + { + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } +}); diff --git a/supabase/functions/transfer-to-logistics/index.ts b/supabase/functions/transfer-to-logistics/index.ts new file mode 100644 index 0000000..911594b --- /dev/null +++ b/supabase/functions/transfer-to-logistics/index.ts @@ -0,0 +1,145 @@ +import { + getOrderUpdateForDeliveryInvitationAction, +} from "../_shared/delivery-invitations.ts"; +import { createServiceClient } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + try { + const body = (await request.json()) as { + orderId?: string; + reason?: string; + note?: string; + targetStatus?: "Передан логисту" | "Платное хранение"; + }; + + if (!body.orderId) { + return new Response(JSON.stringify({ error: "orderId is required" }), { + status: 400, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } + + const supabase = createServiceClient(); + const { data: currentOrder, error: orderError } = await supabase + .from("orders") + .select("id, status, delivery_agreement_status") + .eq("id", body.orderId) + .single(); + + if (orderError) { + throw orderError; + } + + const targetStatus = body.targetStatus || "Передан логисту"; + const action = targetStatus === "Платное хранение" ? "mark_paid_storage" : "transfer_to_logistics"; + const orderUpdate = getOrderUpdateForDeliveryInvitationAction(action); + + const { error: invitationError } = await supabase + .from("delivery_invitations") + .update({ + state: targetStatus === "Платное хранение" ? "paid_storage" : "transferred_to_logistics", + ...(targetStatus === "Платное хранение" + ? { paid_storage_at: new Date().toISOString() } + : { logistics_transferred_at: new Date().toISOString() }), + }) + .eq("order_id", body.orderId); + + if (invitationError) { + throw invitationError; + } + + const { error: updateError } = await supabase + .from("orders") + .update({ + status: orderUpdate?.status, + delivery_agreement_status: body.note || orderUpdate?.deliveryAgreementStatus, + }) + .eq("id", body.orderId); + + if (updateError) { + throw updateError; + } + + const { error: historyError } = await supabase.from("order_history").insert({ + order_id: body.orderId, + action: targetStatus === "Платное хранение" ? "Перевод на платное хранение" : "Передача заказа логисту", + old_status: currentOrder.status, + new_status: orderUpdate?.status, + metadata: { + old_delivery_agreement_status: currentOrder.delivery_agreement_status, + new_delivery_agreement_status: body.note || orderUpdate?.deliveryAgreementStatus, + reason: body.reason || null, + target_status: targetStatus, + }, + }); + + if (historyError) { + throw historyError; + } + + await insertIntegrationEvent(supabase, { + order_id: body.orderId, + event_type: + targetStatus === "Платное хранение" ? "delivery_paid_storage_requested" : "delivery_transfer_to_logistics", + direction: "internal", + status: "success", + payload: { + reason: body.reason || null, + note: body.note || null, + target_status: targetStatus, + }, + }); + + return new Response( + JSON.stringify({ + ok: true, + orderId: body.orderId, + status: orderUpdate?.status, + deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus, + }), + { + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }), + { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }, + ); + } +}); diff --git a/supabase/schema.sql b/supabase/schema.sql index 4bcf7c4..e8fd501 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -25,6 +25,9 @@ create table if not exists public.orders ( manager_id uuid references public.users (id), logistician_id uuid references public.users (id), assigned_driver_id uuid references public.users (id), + ready_for_delivery_at timestamptz, + delivery_flow_started_at timestamptz, + delivery_flow_source text, created_at timestamptz not null default timezone('utc', now()), updated_at timestamptz not null default timezone('utc', now()) ); @@ -56,6 +59,7 @@ create table if not exists public.delivery_slots ( delivery_time text not null, logistician_id uuid references public.users (id), status text not null default 'pending_confirmation', + selected_by_client_at timestamptz, created_at timestamptz not null default timezone('utc', now()) ); @@ -117,6 +121,9 @@ create table if not exists public.integration_events ( alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато'; alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id); +alter table public.orders add column if not exists ready_for_delivery_at timestamptz; +alter table public.orders add column if not exists delivery_flow_started_at timestamptz; +alter table public.orders add column if not exists delivery_flow_source text; alter table public.chat_messages drop constraint if exists chat_messages_channel_check; alter table public.chat_messages add constraint chat_messages_channel_check @@ -132,12 +139,45 @@ alter table public.delivery_invitations add column if not exists logistics_trans alter table public.delivery_invitations add column if not exists paid_storage_at timestamptz; alter table public.delivery_invitations add column if not exists delivered_at timestamptz; alter table public.delivery_invitations add column if not exists updated_at timestamptz not null default timezone('utc', now()); + +alter table public.orders add column if not exists source_order_number text; +alter table public.orders add column if not exists source_order_date date; +alter table public.orders add column if not exists source_customer_name text; +alter table public.orders add column if not exists source_customer_phone text; +alter table public.orders add column if not exists source_customer_email text; +alter table public.orders add column if not exists source_customer_city text; +alter table public.orders add column if not exists source_total_sum numeric; +alter table public.orders add column if not exists source_paid_at timestamptz; +alter table public.orders add column if not exists source_gateway text; +alter table public.orders add column if not exists source_associated_bills_text text; +alter table public.orders add column if not exists source_production_at timestamptz; +alter table public.orders add column if not exists source_saw_at timestamptz; +alter table public.orders add column if not exists source_glue_at timestamptz; +alter table public.orders add column if not exists source_h_glue_at timestamptz; +alter table public.orders add column if not exists source_curve_at timestamptz; +alter table public.orders add column if not exists source_accept_at timestamptz; +alter table public.orders add column if not exists source_ship_at timestamptz; +alter table public.orders add column if not exists source_payload jsonb; +alter table public.orders add column if not exists delivery_set_key text; +alter table public.orders add column if not exists delivery_set_name text; +alter table public.orders add column if not exists delivery_set_status text; +alter table public.orders add column if not exists delivery_set_ready_at timestamptz; +alter table public.orders add column if not exists delivery_ready_reason text; +alter table public.orders add column if not exists source_sms_legacy_at timestamptz; + +comment on column public.orders.source_sms_legacy_at is 'Informational only: legacy 1C SMS timestamp. Must NOT be used to start new delivery automation scenarios.'; + alter table public.integration_events add column if not exists direction text not null default 'internal'; alter table public.integration_events add column if not exists source text not null default 'supabase-function'; alter table public.integration_events add column if not exists status text not null default 'success'; alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb; alter table public.integration_events add column if not exists error_message text; +create index if not exists idx_orders_delivery_set_key on public.orders (delivery_set_key); +create index if not exists idx_orders_delivery_set_status on public.orders (delivery_set_status); +create index if not exists idx_orders_source_accept_at on public.orders (source_accept_at); +create index if not exists idx_orders_source_ship_at on public.orders (source_ship_at); + insert into public.roles (name, permissions) values ( @@ -282,12 +322,15 @@ create index if not exists idx_orders_status on public.orders (status); create index if not exists idx_orders_manager_id on public.orders (manager_id); create index if not exists idx_orders_logistician_id on public.orders (logistician_id); create index if not exists idx_orders_assigned_driver_id on public.orders (assigned_driver_id); +create index if not exists idx_orders_ready_for_delivery_at on public.orders (ready_for_delivery_at); +create index if not exists idx_orders_delivery_flow_started_at on public.orders (delivery_flow_started_at); create index if not exists idx_orders_created_at on public.orders (created_at desc); create index if not exists idx_order_logisticians_order_id on public.order_logisticians (order_id); create index if not exists idx_order_logisticians_logistician_id on public.order_logisticians (logistician_id); create index if not exists idx_order_history_order_id on public.order_history (order_id, created_at desc); create index if not exists idx_delivery_slots_order_id on public.delivery_slots (order_id); create index if not exists idx_delivery_slots_logistician_id on public.delivery_slots (logistician_id); +create index if not exists idx_delivery_slots_selected_by_client_at on public.delivery_slots (selected_by_client_at); create index if not exists idx_chat_messages_order_id on public.chat_messages (order_id, created_at desc); create index if not exists idx_chat_messages_external_message_id on public.chat_messages (external_message_id); create unique index if not exists idx_chat_messages_channel_external_unique @@ -319,16 +362,31 @@ alter table public.error_logs enable row level security; alter table public.delivery_invitations enable row level security; alter table public.integration_events enable row level security; -drop policy if exists "roles admin only" on public.roles; -create policy "roles admin only" on public.roles -for all +drop policy if exists "roles select authenticated" on public.roles; +create policy "roles select authenticated" on public.roles +for select +using (public.current_role_name() is not null); + +drop policy if exists "roles admin mutate" on public.roles; +create policy "roles admin mutate" on public.roles +for insert +with check (public.current_role_name() = 'admin'); + +drop policy if exists "roles admin update" on public.roles; +create policy "roles admin update" on public.roles +for update using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); +drop policy if exists "roles admin delete" on public.roles; +create policy "roles admin delete" on public.roles +for delete +using (public.current_role_name() = 'admin'); + drop policy if exists "users self or admin" on public.users; create policy "users self or admin" on public.users for select -using (id = auth.uid() or public.current_role_name() = 'admin'); +using (public.current_role_name() is not null); drop policy if exists "users admin update" on public.users; create policy "users admin update" on public.users diff --git a/supabase/seed/stage-1-demo.sql b/supabase/seed/stage-1-demo.sql new file mode 100644 index 0000000..7ada517 --- /dev/null +++ b/supabase/seed/stage-1-demo.sql @@ -0,0 +1,732 @@ +-- Stage 1 demo seed for live Supabase-backed demo. +-- Represents grouped imported 1C orders as delivery sets. +-- Run this after the two auth users are created in Supabase Auth: +-- skylanguage@yandex.ru and mk7029953@yandex.ru + +insert into public.roles (name, permissions) +values + ('manager', '["orders.create","orders.update.own","orders.read.own","comments.manage"]'::jsonb), + ('production_lead', '["orders.read.all","production.queue.manage","orders.status.production"]'::jsonb), + ('logistician', '["orders.read.assigned","delivery.manage","chatbots.manage"]'::jsonb), + ('driver', '["orders.read.assigned_driver","orders.status.driver"]'::jsonb), + ('admin', '["*"]'::jsonb) +on conflict (name) do nothing; + +do $$ +declare + admin_auth_id uuid; + logistician_auth_id uuid; + admin_role_id uuid; + logistician_role_id uuid; +begin + select id into admin_auth_id from auth.users where email = 'skylanguage@yandex.ru' limit 1; + select id into logistician_auth_id from auth.users where email = 'mk7029953@yandex.ru' limit 1; + select id into admin_role_id from public.roles where name = 'admin' limit 1; + select id into logistician_role_id from public.roles where name = 'logistician' limit 1; + + if admin_auth_id is not null then + insert into public.users (id, email, name, role_id, last_login) + values ( + admin_auth_id, + 'skylanguage@yandex.ru', + 'Елена Родович', + admin_role_id, + timezone('utc', now()) + ) + on conflict (id) do update + set email = excluded.email, + name = excluded.name, + role_id = excluded.role_id, + last_login = excluded.last_login; + end if; + + if logistician_auth_id is not null then + insert into public.users (id, email, name, role_id, last_login) + values ( + logistician_auth_id, + 'mk7029953@yandex.ru', + 'Михаил Кучер', + logistician_role_id, + timezone('utc', now()) + ) + on conflict (id) do update + set email = excluded.email, + name = excluded.name, + role_id = excluded.role_id, + last_login = excluded.last_login; + end if; +end $$; + +delete from public.order_history where order_id in ( + select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037') +); +delete from public.delivery_slots where order_id in ( + select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037') +); +delete from public.delivery_invitations where order_id in ( + select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037') +); +delete from public.integration_events where order_id in ( + select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037') +); +delete from public.chat_messages where order_id in ( + select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037') +); +delete from public.order_logisticians where order_id in ( + select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037') +); + +-- Delivery set: Волкова — approaching (one order accepted, one still in production) +-- CD-240031: kitchen set, accepted by QC, ready for delivery +-- CD-240036: countertop for same client, still in production (saw done) + +insert into public.orders ( + order_number, + customer, + status, + delivery_agreement_status, + manager_id, + logistician_id, + assigned_driver_id, + ready_for_delivery_at, + delivery_flow_started_at, + delivery_flow_source, + source_order_number, + source_order_date, + source_customer_name, + source_customer_phone, + source_customer_email, + source_customer_city, + source_total_sum, + source_paid_at, + source_gateway, + source_associated_bills_text, + source_production_at, + source_saw_at, + source_glue_at, + source_h_glue_at, + source_curve_at, + source_accept_at, + source_ship_at, + source_payload, + delivery_set_key, + delivery_set_name, + delivery_set_status, + delivery_set_ready_at +) +values +( + 'CD-240031', + jsonb_build_object( + 'name', 'Мария Волкова', + 'phone', '+7 978 000-12-31', + 'messenger', 'Телеграм', + 'address', 'Симферополь, ул. Тургенева, 18', + 'city', 'Симферополь', + 'items', jsonb_build_array( + 'Кухонный гарнитур | 1 комплект', + 'Фурнитура Blum | 12 шт', + 'Монтажный комплект | 1 набор' + ), + 'comments', jsonb_build_array('Клиент просит подтверждение за 2 часа до доставки'), + 'tags', jsonb_build_array('срочно', 'кухня'), + 'orderNotes', jsonb_build_array( + jsonb_build_object( + 'id', 'note-1', + 'authorName', 'Елена Родович', + 'text', 'Проверить доступность подъезда для разгрузки.', + 'createdAt', '2026-04-12T08:00:00Z' + ) + ), + 'internalMessages', jsonb_build_array( + jsonb_build_object( + 'id', 'ic-1', + 'senderId', 'unknown', + 'senderName', 'Логист', + 'text', 'Клиент просил предварительный звонок перед доставкой.', + 'sentAt', '2026-04-12T09:50:00Z' + ) + ), + 'scheduledDelivery', '2026-04-14T08:00:00Z', + 'deliveryDate', '2026-04-14', + 'deliveryTime', 'Первая половина дня', + 'exception', null + ), + 'Ожидает ответа клиента', + 'Отправлено клиенту', + (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), + (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), + null, + '2026-04-13T08:30:00Z', + '2026-04-13T09:00:00Z', + '1c_xml', + 'УН-00031', + '2026-04-10', + 'Волкова Мария Александровна', + '+79780001231', + 'volkova@example.com', + 'Симферополь', + 185400.00, + '2026-04-11T10:00:00Z', + 'bank_transfer', + 'Счет №31 от 10.04.2026', + '2026-04-11T09:00:00Z', + '2026-04-11T10:00:00Z', + '2026-04-12T08:00:00Z', + null, + null, + '2026-04-12T14:00:00Z', + null, + jsonb_build_object('source', '1c_xml', 'imported_at', '2026-04-13T08:00:00Z'), + 'volkova-simferopol-2026-04', + 'Волкова М.А. — Симферополь, апрель 2026', + 'approaching', + null +), +( + 'CD-240036', + jsonb_build_object( + 'name', 'Мария Волкова', + 'phone', '+7 978 000-12-31', + 'messenger', 'Телеграм', + 'address', 'Симферополь, ул. Тургенева, 18', + 'city', 'Симферополь', + 'items', jsonb_build_array('Столешница 3000x600 | 1 шт'), + 'comments', jsonb_build_array(), + 'tags', jsonb_build_array('столешница'), + 'orderNotes', jsonb_build_array(), + 'internalMessages', jsonb_build_array(), + 'scheduledDelivery', null, + 'deliveryDate', null, + 'deliveryTime', null, + 'exception', null + ), + 'В производстве', + 'Не начато', + (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), + null, + null, + null, + null, + null, + 'УН-00036', + '2026-04-11', + 'Волкова Мария Александровна', + '+79780001231', + 'volkova@example.com', + 'Симферополь', + 42800.00, + '2026-04-11T10:00:00Z', + 'bank_transfer', + 'Счет №31 от 10.04.2026', + '2026-04-12T09:00:00Z', + '2026-04-12T14:00:00Z', + null, + null, + null, + null, + null, + jsonb_build_object('source', '1c_xml', 'imported_at', '2026-04-13T08:00:00Z'), + 'volkova-simferopol-2026-04', + 'Волкова М.А. — Симферополь, апрель 2026', + 'approaching', + null +), + +-- Delivery set: Савин — ready_to_launch (all orders accepted, none shipped) + +( + 'CD-240032', + jsonb_build_object( + 'name', 'Александр Савин', + 'phone', '+7 978 000-12-32', + 'messenger', 'ВКонтакте', + 'address', 'Ялта, ул. Чехова, 9', + 'city', 'Ялта', + 'items', jsonb_build_array( + 'Стеклопакет 2400x1800 | 2 шт', + 'Комплект крепежа | 1 набор' + ), + 'comments', jsonb_build_array('Нужен созвон перед отгрузкой'), + 'tags', jsonb_build_array('стеклопакет'), + 'orderNotes', jsonb_build_array( + jsonb_build_object( + 'id', 'note-2', + 'authorName', 'Елена Родович', + 'text', 'Производство завершено, передаём логистике после фотофиксации.', + 'createdAt', '2026-04-13T08:35:00Z' + ) + ), + 'internalMessages', jsonb_build_array( + jsonb_build_object( + 'id', 'ic-2', + 'senderId', 'unknown', + 'senderName', 'Производство', + 'text', 'Можно запускать сообщение клиенту после 14:00.', + 'sentAt', '2026-04-13T08:25:00Z' + ) + ), + 'scheduledDelivery', '2026-04-15T13:00:00Z', + 'deliveryDate', '2026-04-15', + 'deliveryTime', 'Вторая половина дня', + 'exception', null + ), + 'Доставка согласована', + 'Подтверждено клиентом', + (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), + (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), + null, + '2026-04-13T07:10:00Z', + '2026-04-13T07:20:00Z', + '1c_xml', + 'УН-00032', + '2026-04-09', + 'Савин Александр Петрович', + '+79780001232', + 'savin@example.com', + 'Ялта', + 124600.00, + '2026-04-10T12:00:00Z', + 'bank_transfer', + 'Счет №32 от 09.04.2026', + '2026-04-10T08:00:00Z', + '2026-04-10T14:00:00Z', + '2026-04-11T09:00:00Z', + null, + null, + '2026-04-12T16:00:00Z', + null, + jsonb_build_object('source', '1c_xml', 'imported_at', '2026-04-13T07:00:00Z'), + 'savin-yalta-2026-04', + 'Савин А.П. — Ялта, апрель 2026', + 'ready_to_launch', + '2026-04-12T16:00:00Z' +), + +-- Delivery set: Тарасова — awaiting_client (sent to client, waiting for response) + +( + 'CD-240033', + jsonb_build_object( + 'name', 'Екатерина Тарасова', + 'phone', '+7 978 000-12-33', + 'messenger', 'Макс', + 'address', 'Севастополь, пр. Октябрьской Революции, 51', + 'city', 'Севастополь', + 'items', jsonb_build_array('Столешница | 3 шт', 'Кромка | 8 рулонов'), + 'comments', jsonb_build_array('Клиент просит вечерний слот'), + 'tags', jsonb_build_array('розница'), + 'orderNotes', jsonb_build_array(), + 'internalMessages', jsonb_build_array(), + 'scheduledDelivery', '2026-04-16T17:00:00Z', + 'deliveryDate', '2026-04-16', + 'deliveryTime', 'Первая половина дня', + 'exception', null + ), + 'Передан логисту', + 'Нет ответа', + (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), + (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), + null, + '2026-04-13T11:40:00Z', + '2026-04-13T12:10:00Z', + '1c_xml', + 'УН-00033', + '2026-04-08', + 'Тарасова Екатерина Игоревна', + '+79780001233', + 'tarasova@example.com', + 'Севастополь', + 67200.00, + '2026-04-09T09:00:00Z', + 'card', + 'Счет №33 от 08.04.2026', + '2026-04-09T08:00:00Z', + '2026-04-09T14:00:00Z', + '2026-04-10T10:00:00Z', + null, + null, + '2026-04-11T15:00:00Z', + null, + jsonb_build_object('source', '1c_xml', 'imported_at', '2026-04-13T11:00:00Z'), + 'tarasova-sevastopol-2026-04', + 'Тарасова Е.И. — Севастополь, апрель 2026', + 'awaiting_client', + '2026-04-11T15:00:00Z' +), + +-- Delivery set: Фролова — manual_work (paid storage) + +( + 'CD-240034', + jsonb_build_object( + 'name', 'Ирина Фролова', + 'phone', '+7 978 000-12-34', + 'messenger', 'СМС', + 'address', 'Феодосия, ул. Крымская, 12', + 'city', 'Феодосия', + 'items', jsonb_build_array('ДСП | 12 листов', 'Кромка | 16 рулонов'), + 'comments', jsonb_build_array('Клиент ждёт звонок утром'), + 'tags', jsonb_build_array('опт'), + 'orderNotes', jsonb_build_array(), + 'internalMessages', jsonb_build_array(), + 'scheduledDelivery', '2026-04-17T10:00:00Z', + 'deliveryDate', '2026-04-17', + 'deliveryTime', 'Вторая половина дня', + 'exception', null + ), + 'Платное хранение', + 'Нет ответа', + (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), + (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), + null, + '2026-04-13T10:00:00Z', + '2026-04-13T12:30:00Z', + '1c_xml', + 'УН-00034', + '2026-04-07', + 'Фролова Ирина Дмитриевна', + '+79780001234', + null, + 'Феодосия', + 98700.00, + '2026-04-08T11:00:00Z', + 'cash', + 'Счет №34 от 07.04.2026', + '2026-04-08T08:00:00Z', + '2026-04-08T13:00:00Z', + '2026-04-09T09:00:00Z', + null, + null, + '2026-04-10T11:00:00Z', + null, + jsonb_build_object('source', '1c_xml', 'imported_at', '2026-04-13T09:30:00Z'), + 'frolova-feodosia-2026-04', + 'Фролова И.Д. — Феодосия, апрель 2026', + 'manual_work', + '2026-04-10T11:00:00Z' +), + +-- Single order in set: Орлова — completed delivery + +( + 'CD-240035', + jsonb_build_object( + 'name', 'Наталья Орлова', + 'phone', '+7 978 000-12-35', + 'messenger', 'Телеграм', + 'address', 'Симферополь, ул. Жуковского, 4', + 'city', 'Симферополь', + 'items', jsonb_build_array('Фурнитура | 24 позиции'), + 'comments', jsonb_build_array('Требуется подтверждение доставки в день заказа'), + 'tags', jsonb_build_array('vip'), + 'orderNotes', jsonb_build_array(), + 'internalMessages', jsonb_build_array(), + 'scheduledDelivery', '2026-04-13T15:00:00Z', + 'deliveryDate', '2026-04-13', + 'deliveryTime', 'Первая половина дня', + 'exception', null + ), + 'Доставлен', + 'Подтверждено клиентом', + (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), + (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), + null, + '2026-04-13T05:30:00Z', + '2026-04-13T06:00:00Z', + '1c_xml', + 'УН-00035', + '2026-04-06', + 'Орлова Наталья Сергеевна', + '+79780001235', + 'orlova@example.com', + 'Симферополь', + 31500.00, + '2026-04-07T08:00:00Z', + 'card', + 'Счет №35 от 06.04.2026', + '2026-04-07T09:00:00Z', + '2026-04-07T14:00:00Z', + '2026-04-08T08:00:00Z', + null, + null, + '2026-04-10T12:00:00Z', + '2026-04-13T14:30:00Z', + jsonb_build_object('source', '1c_xml', 'imported_at', '2026-04-13T05:00:00Z'), + 'orlova-simferopol-2026-04', + 'Орлова Н.С. — Симферополь, апрель 2026', + 'completed', + '2026-04-10T12:00:00Z' +), + +-- New order with no source data: freshly imported from 1C, not yet grouped +( + 'CD-240037', + jsonb_build_object( + 'name', 'Дмитрий Козлов', + 'phone', '+7 978 000-12-37', + 'messenger', 'Телеграм', + 'address', 'Алушта, ул. Ленина, 45', + 'city', 'Алушта', + 'items', jsonb_build_array('Душевая кабина | 1 шт', 'Монтажный комплект | 1 набор'), + 'comments', jsonb_build_array('Въезд со двора, нужна узкая машина'), + 'tags', jsonb_build_array('душевая', 'срочно'), + 'orderNotes', jsonb_build_array(), + 'internalMessages', jsonb_build_array(), + 'scheduledDelivery', null, + 'deliveryDate', null, + 'deliveryTime', null, + 'exception', null + ), + 'Новый', + 'Не начато', + (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), + null, + null, + null, + null, + null, + 'УН-00037', + '2026-04-13', + 'Козлов Дмитрий Викторович', + '+79780001237', + null, + 'Алушта', + 78000.00, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + jsonb_build_object('source', '1c_xml', 'imported_at', '2026-04-13T14:00:00Z'), + null, + null, + null, + null +) +on conflict (order_number) do update set + customer = excluded.customer, + status = excluded.status, + delivery_agreement_status = excluded.delivery_agreement_status, + manager_id = excluded.manager_id, + logistician_id = excluded.logistician_id, + assigned_driver_id = excluded.assigned_driver_id, + ready_for_delivery_at = excluded.ready_for_delivery_at, + delivery_flow_started_at = excluded.delivery_flow_started_at, + delivery_flow_source = excluded.delivery_flow_source, + source_order_number = excluded.source_order_number, + source_order_date = excluded.source_order_date, + source_customer_name = excluded.source_customer_name, + source_customer_phone = excluded.source_customer_phone, + source_customer_email = excluded.source_customer_email, + source_customer_city = excluded.source_customer_city, + source_total_sum = excluded.source_total_sum, + source_paid_at = excluded.source_paid_at, + source_gateway = excluded.source_gateway, + source_associated_bills_text = excluded.source_associated_bills_text, + source_production_at = excluded.source_production_at, + source_saw_at = excluded.source_saw_at, + source_glue_at = excluded.source_glue_at, + source_h_glue_at = excluded.source_h_glue_at, + source_curve_at = excluded.source_curve_at, + source_accept_at = excluded.source_accept_at, + source_ship_at = excluded.source_ship_at, + source_payload = excluded.source_payload, + delivery_set_key = excluded.delivery_set_key, + delivery_set_name = excluded.delivery_set_name, + delivery_set_status = excluded.delivery_set_status, + delivery_set_ready_at = excluded.delivery_set_ready_at, + delivery_ready_reason = excluded.delivery_ready_reason, + source_sms_legacy_at = excluded.source_sms_legacy_at, + updated_at = timezone('utc', now()); + +insert into public.order_logisticians (order_id, logistician_id, assigned_by) +select o.id, u.id, u.id +from public.orders o +cross join lateral ( + select id from public.users where email = 'mk7029953@yandex.ru' limit 1 +) u +where o.order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034') +on conflict do nothing; + +insert into public.delivery_slots ( + order_id, + delivery_date, + delivery_time, + logistician_id, + status, + selected_by_client_at +) +select + o.id, + '2026-04-14'::date, + 'Первая половина дня', + (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), + 'pending_confirmation', + null +from public.orders o +where o.order_number = 'CD-240031'; + +insert into public.delivery_slots ( + order_id, + delivery_date, + delivery_time, + logistician_id, + status, + selected_by_client_at +) +select + o.id, + '2026-04-15'::date, + 'Вторая половина дня', + (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), + 'confirmed', + timezone('utc', now()) +from public.orders o +where o.order_number = 'CD-240032'; + +insert into public.delivery_invitations ( + order_id, + token_hash, + state, + order_number, + customer_name, + customer_phone, + customer_messenger, + available_slots, + delivery_date, + delivery_time, + sent_at, + opened_at, + confirmed_at +) +select + o.id, + encode(digest('demo-invite-1001', 'sha256'), 'hex'), + 'awaiting_choice', + o.order_number, + o.customer ->> 'name', + o.customer ->> 'phone', + o.customer ->> 'messenger', + array[ + '14 апреля, первая половина дня', + '14 апреля, вторая половина дня', + '15 апреля, первая половина дня', + '15 апреля, вторая половина дня' + ], + '2026-04-14'::date, + 'Первая половина дня', + '2026-04-13T09:00:00Z', + '2026-04-13T09:10:00Z', + null +from public.orders o +where o.order_number = 'CD-240031' +on conflict (order_id) do update set + token_hash = excluded.token_hash, + state = excluded.state, + order_number = excluded.order_number, + customer_name = excluded.customer_name, + customer_phone = excluded.customer_phone, + customer_messenger = excluded.customer_messenger, + available_slots = excluded.available_slots, + delivery_date = excluded.delivery_date, + delivery_time = excluded.delivery_time, + sent_at = excluded.sent_at, + opened_at = excluded.opened_at, + confirmed_at = excluded.confirmed_at, + updated_at = timezone('utc', now()); + +insert into public.integration_events ( + order_id, + event_type, + direction, + source, + status, + payload, + error_message +) +select + o.id, + 'delivery_invitation_created', + 'outbound', + 'seed', + 'success', + jsonb_build_object( + 'token', 'demo-invite-1001', + 'availableSlots', array[ + '14 апреля, первая половина дня', + '14 апреля, вторая половина дня', + '15 апреля, первая половина дня', + '15 апреля, вторая половина дня' + ] + ), + null +from public.orders o +where o.order_number = 'CD-240031'; + +insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) +select o.id, 'Заказ импортирован из 1С', null, 'Новый', (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), jsonb_build_object('source', '1c_xml_import') +from public.orders o where o.order_number = 'CD-240031'; + +insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) +select o.id, 'Заказ готов к отгрузке', 'В производстве', 'Готов к отгрузке', (select id from public.users where email = 'skylanguage@yandex.ru' limit 1), jsonb_build_object('source', '1c_xml') +from public.orders o where o.order_number = 'CD-240031'; + +insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) +select o.id, 'Запущено согласование доставки', 'Готов к отгрузке', 'Ожидает ответа клиента', (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), jsonb_build_object('source', 'seed') +from public.orders o where o.order_number = 'CD-240031'; + +insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) +select o.id, 'Доставка согласована', 'Готов к отгрузке', 'Доставка согласована', (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), jsonb_build_object('source', 'seed') +from public.orders o where o.order_number = 'CD-240032'; + +insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) +select o.id, 'Передан логисту', 'Ожидает ответа клиента', 'Передан логисту', (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), jsonb_build_object('source', 'seed') +from public.orders o where o.order_number = 'CD-240033'; + +insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) +select o.id, 'Платное хранение', 'Передан логисту', 'Платное хранение', (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), jsonb_build_object('source', 'seed') +from public.orders o where o.order_number = 'CD-240034'; + +insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata) +select o.id, 'Доставка завершена', 'В пути', 'Доставлен', (select id from public.users where email = 'mk7029953@yandex.ru' limit 1), jsonb_build_object('source', 'seed') +from public.orders o where o.order_number = 'CD-240035'; + +insert into public.chat_messages ( + order_id, + sender_name, + sender_type, + channel, + text, + payload +) +select + o.id, + 'Система', + 'bot', + 'sms', + 'Заказ CD-240031 готов. Выберите дату и половину дня доставки.', + jsonb_build_object('source', 'seed') +from public.orders o where o.order_number = 'CD-240031'; + +insert into public.chat_messages ( + order_id, + sender_name, + sender_type, + channel, + text, + payload +) +select + o.id, + 'Мария Волкова', + 'client', + 'telegram', + 'Подтвержу позже, вернусь после 16:00.', + jsonb_build_object('source', 'seed') +from public.orders o where o.order_number = 'CD-240031'; \ No newline at end of file