diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..f85a35d --- /dev/null +++ b/deno.lock @@ -0,0 +1,33 @@ +{ + "version": "5", + "remote": { + "https://esm.sh/@supabase/supabase-js@2.49.8": "fd72c6e822ed41d5fe7ad3bbe3a48420abbb21a579c73d532b36a6467f5b5f7d" + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@eslint/js@^9.22.0", + "npm:@supabase/supabase-js@^2.52.0", + "npm:@types/react-dom@^18.3.5", + "npm:@types/react@^18.3.18", + "npm:@vitejs/plugin-react@^4.3.4", + "npm:autoprefixer@^10.4.21", + "npm:clsx@^2.1.1", + "npm:date-fns@^4.1.0", + "npm:eslint-plugin-react-hooks@^5.2.0", + "npm:eslint-plugin-react@^7.37.5", + "npm:eslint@^9.22.0", + "npm:framer-motion@^12.7.4", + "npm:globals@16", + "npm:postcss@^8.5.3", + "npm:react-dom@^18.3.1", + "npm:react-router-dom@^7.3.0", + "npm:react@^18.3.1", + "npm:tailwind-merge@^3.3.0", + "npm:tailwindcss@^3.4.17", + "npm:vite@^6.2.0", + "npm:vitest@^3.0.9" + ] + } + } +} diff --git a/docs/n8n-order-group-delivery-flow.md b/docs/n8n-order-group-delivery-flow.md new file mode 100644 index 0000000..7e8ebc0 --- /dev/null +++ b/docs/n8n-order-group-delivery-flow.md @@ -0,0 +1,251 @@ +# n8n Flow For `order_groups` + +## Goal + +`Supabase` stores the delivery state, generates the client link, and saves all timestamps. +`n8n` is responsible for sending SMS, retrying, and moving stalled groups into manual handling. + +The short link is not needed on our side. We store the full `delivery_link`, and the SMS provider shortens it during delivery. + +## Source Of Truth + +The main record is `public.order_groups`. + +Important fields: + +- `status` - business readiness of the group +- `delivery_status` - delivery coordination state for the client and logistics +- `delivery_link` - full public link to `/delivery/:token` +- `delivery_invitation_id` - related invitation record +- `notification_status` - SMS orchestration state for `n8n` +- `sms_attempts` - how many SMS attempts were made +- `first_sms_sent_at` - timestamp of the first SMS +- `second_sms_sent_at` - timestamp of the second SMS +- `last_sms_error` - last provider error text +- `next_notification_check_at` - when `n8n` should revisit the record +- `delivery_date` and `delivery_time` - selected slot after client confirmation + +## Recommended Status Model + +### `delivery_status` + +- `pending_confirmation` - client has not selected a slot yet +- `agreed` - client selected a delivery slot +- `manual_confirmation_required` - automatic flow failed, manager/logistics must continue manually +- `assigned_to_driver` - delivery is approved and handed over to driver planning +- `out_for_delivery` - driver is already working on it +- `delivered` - delivery completed +- `cancelled` - group should no longer be processed + +### `notification_status` + +- `not_started` - link has not been prepared yet +- `link_ready` - Supabase created `delivery_link`, `n8n` can send the first SMS +- `first_sms_sent` - first SMS was accepted by provider +- `second_sms_sent` - reminder SMS was accepted by provider +- `confirmed` - client selected a slot +- `manual_required` - no confirmation after retries +- `send_failed` - provider/API error, retry allowed + +## Supabase Responsibilities + +### 1. Prepare the link + +When an `order_group` is moved into the client-delivery flow: + +- `status = 'ready_for_notification'` +- `delivery_status = 'pending_confirmation'` + +Supabase trigger `order_groups_ensure_delivery_link` should: + +- create `delivery_invitations` row with `order_group_id` +- generate token and full public URL +- write `delivery_link` into `order_groups` +- set `delivery_invitation_id` +- set `notification_status = 'link_ready'` +- set `next_notification_check_at = now()` + +The SQL for this trigger lives in: + +```text +docs/sql/order-groups-auto-delivery-link.sql +``` + +`n8n` no longer has to call `create-delivery-invitation` for `order_groups`. It should wait until the row already has `notification_status = 'link_ready'` and `delivery_link is not null`. + +### 2. Accept client choice + +The public client page uses the token. +When the client confirms a slot, `confirm-delivery-choice` should: + +- store `delivery_date` and `delivery_time` +- set `delivery_status = 'agreed'` +- set `notification_status = 'confirmed'` + +That change becomes the stop signal for all reminder workflows in `n8n`. + +## n8n Workflows + +### Workflow 1. First SMS sender + +Trigger: + +- Cron every 5-10 minutes +- Optional backup webhook trigger if you later want push-based start + +Query: + +- `status = 'ready_for_notification'` +- `delivery_status = 'pending_confirmation'` +- `notification_status = 'link_ready'` +- `delivery_link is not null` + +Action: + +- send SMS with `delivery_link` + +On success update `order_groups`: + +- `notification_status = 'first_sms_sent'` +- `sms_attempts = 1` +- `first_sms_sent_at = now()` +- `sms_sent_at = now()` +- `last_sms_error = null` +- `next_notification_check_at = now() + interval '1 hour'` + +On failure update `order_groups`: + +- `notification_status = 'send_failed'` +- `last_sms_error = ` +- `next_notification_check_at = now() + interval '10 minutes'` + +## Workflow 2. Delivery watchdog + +Trigger: + +- Cron every 10 minutes + +Purpose: + +- find records where first workflow did not finish cleanly +- retry failed first sends + +Query candidates: + +- `notification_status = 'send_failed'` +- `delivery_status = 'pending_confirmation'` +- `next_notification_check_at <= now()` + +Behavior: + +- retry first SMS +- if success, move to `first_sms_sent` +- if repeated failures exceed your chosen threshold, move to `manual_required` + +## Workflow 3. Reminder SMS + +Trigger: + +- Cron every 10 minutes + +Query: + +- `delivery_status = 'pending_confirmation'` +- `notification_status = 'first_sms_sent'` +- `next_notification_check_at <= now()` + +Action: + +- send second SMS reminder with the same `delivery_link` + +On success update: + +- `notification_status = 'second_sms_sent'` +- `sms_attempts = 2` +- `second_sms_sent_at = now()` +- `last_sms_error = null` +- `next_notification_check_at = now() + interval '3 hours'` + +On failure update: + +- `notification_status = 'send_failed'` +- `last_sms_error = ` +- `next_notification_check_at = now() + interval '30 minutes'` + +## Workflow 4. Manual handoff + +Trigger: + +- Cron every 10 minutes + +Query: + +- `delivery_status = 'pending_confirmation'` +- `notification_status = 'second_sms_sent'` +- `next_notification_check_at <= now()` + +Action: + +- stop automatic reminders +- move the group into manual handling + +Update: + +- `delivery_status = 'manual_confirmation_required'` +- `notification_status = 'manual_required'` + +## Workflow 5. Stop conditions + +Every workflow must ignore rows where: + +- `delivery_status in ('agreed', 'assigned_to_driver', 'out_for_delivery', 'delivered', 'cancelled')` +- `notification_status in ('confirmed', 'manual_required')` + +This prevents duplicate SMS after the client already responded or the case was handed to a person. + +## Suggested SMS Text + +Example: + +```text +Ваш заказ готов к согласованию доставки. +Выберите удобные дату и время по ссылке: +{{delivery_link}} +``` + +Reminder: + +```text +Напоминаем: нужно выбрать дату и время доставки вашего заказа. +Ссылка: +{{delivery_link}} +``` + +## What Frontend Needs + +The frontend public page only needs: + +- token from URL +- `get-delivery-invitation` +- `confirm-delivery-choice` + +No SMS logic should live in the frontend. +No link generation should live in the frontend. + +## Minimal Rollout Order + +1. Deploy updated `Supabase` schema and `docs/sql/order-groups-auto-delivery-link.sql`. +2. Verify that insert/update in `order_groups` writes `delivery_link` and `notification_status = 'link_ready'`. +3. Build `n8n` workflow for first SMS. +4. Build `n8n` reminder workflow. +5. Build `n8n` manual-handoff workflow. +6. Test full cycle on one real `order_group`. + +## Test Scenario + +1. Mark one `order_group` as ready for client delivery. +2. Confirm that `delivery_link` appeared in `order_groups` automatically. +4. Let `n8n` send the first SMS. +5. Open the link and confirm a slot on the client page. +6. Confirm that `delivery_status = 'agreed'` and `notification_status = 'confirmed'`. +7. Confirm that reminder workflows no longer touch this group. diff --git a/docs/sql/order-groups-anon-n8n-insert-policy.sql b/docs/sql/order-groups-anon-n8n-insert-policy.sql new file mode 100644 index 0000000..5ca315a --- /dev/null +++ b/docs/sql/order-groups-anon-n8n-insert-policy.sql @@ -0,0 +1,115 @@ +-- Allow n8n to insert order_groups with the anon key, but only with a private +-- integration secret passed in the x-n8n-secret HTTP header. +-- +-- n8n REST headers: +-- apikey: +-- Authorization: Bearer +-- x-n8n-secret: +-- Content-Type: application/json +-- Prefer: resolution=merge-duplicates,return=representation +-- +-- Endpoint: +-- POST https://supa.supersamsev.ru/rest/v1/order_groups +-- +-- Important: +-- - Keep this secret only in n8n credentials/environment. +-- - Do not put it in the frontend. +-- - Replace CHANGE_ME_LONG_RANDOM_SECRET before running this SQL. + +create extension if not exists pgcrypto; + +create table if not exists public.integration_api_secrets ( + name text primary key, + secret_hash text not null, + created_at timestamptz not null default now() +); + +alter table public.integration_api_secrets enable row level security; + +drop policy if exists "integration api secrets admin only" on public.integration_api_secrets; +create policy "integration api secrets admin only" +on public.integration_api_secrets +for all +using (public.current_role_name() = 'admin') +with check (public.current_role_name() = 'admin'); + +insert into public.integration_api_secrets (name, secret_hash) +values ( + 'n8n_order_groups_insert', + crypt('CHANGE_ME_LONG_RANDOM_SECRET', gen_salt('bf')) +) +on conflict (name) do update +set secret_hash = excluded.secret_hash; + +create or replace function public.is_valid_n8n_order_groups_secret() +returns boolean +language sql +stable +security definer +set search_path = public +as $$ + select coalesce( + exists ( + select 1 + from public.integration_api_secrets s + where s.name = 'n8n_order_groups_insert' + and crypt( + nullif(current_setting('request.headers', true)::jsonb ->> 'x-n8n-secret', ''), + s.secret_hash + ) = s.secret_hash + ), + false + ); +$$; + +revoke all on function public.is_valid_n8n_order_groups_secret() from public; +grant execute on function public.is_valid_n8n_order_groups_secret() to anon, authenticated; + +alter table public.order_groups enable row level security; + +drop policy if exists "order groups insert service roles" on public.order_groups; +drop policy if exists "order groups insert coordination and integration roles" on public.order_groups; +drop policy if exists "order groups insert n8n anon secret" on public.order_groups; + +create policy "order groups insert n8n anon secret" +on public.order_groups +for insert +to anon +with check (public.is_valid_n8n_order_groups_secret()); + +create policy "order groups insert coordination and integration roles" +on public.order_groups +for insert +to authenticated +with check ( + public.current_role_name() in ('manager', 'logistician', 'admin', 'integration') +); + +-- If n8n uses upsert, update must also be allowed for the same anon secret. +drop policy if exists "order groups update n8n anon secret" on public.order_groups; +drop policy if exists "order groups update coordination roles" on public.order_groups; +drop policy if exists "order groups update coordination and integration roles" on public.order_groups; + +create policy "order groups update n8n anon secret" +on public.order_groups +for update +to anon +using (public.is_valid_n8n_order_groups_secret()) +with check (public.is_valid_n8n_order_groups_secret()); + +create policy "order groups update coordination and integration roles" +on public.order_groups +for update +to authenticated +using ( + public.current_role_name() in ('manager', 'logistician', 'admin', 'integration') +) +with check ( + public.current_role_name() in ('manager', 'logistician', 'admin', 'integration') +); + +-- Diagnostics: +-- select policyname, cmd, roles, qual, with_check +-- from pg_policies +-- where schemaname = 'public' and tablename = 'order_groups' +-- order by policyname; diff --git a/docs/sql/order-groups-auto-delivery-link.sql b/docs/sql/order-groups-auto-delivery-link.sql new file mode 100644 index 0000000..29f9c09 --- /dev/null +++ b/docs/sql/order-groups-auto-delivery-link.sql @@ -0,0 +1,159 @@ +-- Auto-create public delivery links for rows imported into public.order_groups. +-- Run this in Supabase SQL Editor after the order_groups delivery columns exist. + +create extension if not exists pgcrypto with schema extensions; + +create or replace function public.build_order_group_default_available_slots( + start_from timestamptz default now() +) +returns text[] +language sql +stable +as $$ + with candidate_days as ( + select day::date as delivery_day + from generate_series( + (start_from at time zone 'Europe/Simferopol')::date + 1, + (start_from at time zone 'Europe/Simferopol')::date + 21, + interval '1 day' + ) as day + where extract(isodow from day) between 1 and 5 + order by day + limit 5 + ), + slots as ( + select format('%s, %s', delivery_day, half_day) as slot_name + from candidate_days + cross join ( + values ('Первая половина дня'), ('Вторая половина дня') + ) as halves(half_day) + ) + select coalesce(array_agg(slot_name order by slot_name), array[]::text[]) + from slots; +$$; + +create or replace function public.ensure_order_group_delivery_link() +returns trigger +language plpgsql +security definer +set search_path = public, extensions +as $$ +declare + v_customer_name text; + v_customer_phone text; + v_order_number text; + v_token text; + v_token_hash text; + v_delivery_link text; + v_invitation_id uuid; + v_base_url text := 'https://dost.supersamsev.ru'; +begin + if pg_trigger_depth() > 1 then + return new; + end if; + + if coalesce(new.status, '') <> 'ready_for_notification' then + return new; + end if; + + if coalesce(new.delivery_status, 'pending_confirmation') <> 'pending_confirmation' then + return new; + end if; + + if coalesce(new.notification_status, 'not_started') not in ('not_started', 'send_failed') then + return new; + end if; + + if new.delivery_link is not null or new.delivery_invitation_id is not null then + return new; + end if; + + v_customer_name := nullif( + coalesce(new.customer_name, new.customer ->> 'name'), + '' + ); + v_customer_phone := nullif( + coalesce( + new.customer_phone_normalized, + new.customer_phone, + new.customer ->> 'phone_normalized', + new.customer ->> 'phone', + split_part(new.group_key, '|', 1) + ), + '' + ); + v_order_number := coalesce(new.group_key::text, new.order_numbers[1]::text); + + if v_customer_phone is null then + update public.order_groups + set + notification_status = 'manual_required', + last_sms_error = 'Не найден телефон клиента для формирования SMS-ссылки', + next_notification_check_at = null, + updated_at = timezone('utc', now()) + where id = new.id; + + return new; + end if; + + v_token := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', ''); + v_token_hash := encode(digest(v_token, 'sha256'), 'hex'); + v_delivery_link := v_base_url || '/delivery/' || v_token; + + insert into public.delivery_invitations ( + order_id, + order_group_id, + token_hash, + state, + order_number, + customer_name, + customer_phone, + available_slots, + expires_at, + sent_at + ) + values ( + null, + new.id, + v_token_hash, + 'awaiting_choice', + v_order_number, + v_customer_name, + v_customer_phone, + public.build_order_group_default_available_slots(), + timezone('utc', now()) + interval '7 days', + null + ) + returning id into v_invitation_id; + + update public.order_groups + set + delivery_invitation_id = v_invitation_id, + delivery_link = v_delivery_link, + notification_status = 'link_ready', + last_sms_error = null, + next_notification_check_at = timezone('utc', now()), + updated_at = timezone('utc', now()) + where id = new.id; + + return new; +end; +$$; + +drop trigger if exists order_groups_ensure_delivery_link on public.order_groups; +create trigger order_groups_ensure_delivery_link +after insert or update of status, delivery_status, notification_status, delivery_link, delivery_invitation_id +on public.order_groups +for each row +execute function public.ensure_order_group_delivery_link(); + +-- Backfill links for already imported groups that are still waiting for SMS. +update public.order_groups +set + notification_status = notification_status, + updated_at = timezone('utc', now()) +where status = 'ready_for_notification' + and delivery_status = 'pending_confirmation' + and notification_status in ('not_started', 'send_failed') + and delivery_link is null + and delivery_invitation_id is null; diff --git a/docs/sql/order-groups-manual-delivery-choice.sql b/docs/sql/order-groups-manual-delivery-choice.sql new file mode 100644 index 0000000..fb0aca6 --- /dev/null +++ b/docs/sql/order-groups-manual-delivery-choice.sql @@ -0,0 +1,27 @@ +alter table public.order_groups add column if not exists delivery_date date; +alter table public.order_groups add column if not exists delivery_time text; +alter table public.order_groups add column if not exists notification_status text not null default 'not_started'; + +create index if not exists idx_order_groups_delivery_status +on public.order_groups (delivery_status); + +create index if not exists idx_order_groups_notification_status +on public.order_groups (notification_status, updated_at); + +alter table public.order_groups enable row level security; + +drop policy if exists "order groups select by role" on public.order_groups; +create policy "order groups select by role" on public.order_groups +for select +using (true); + +drop policy if exists "order groups update coordination roles" on public.order_groups; +create policy "order groups update coordination roles" on public.order_groups +for update +using (public.current_role_name() in ('manager', 'logistician', 'admin')) +with check (public.current_role_name() in ('manager', 'logistician', 'admin')); + +drop policy if exists "order groups insert service roles" on public.order_groups; +create policy "order groups insert service roles" on public.order_groups +for insert +with check (public.current_role_name() in ('manager', 'logistician', 'admin')); diff --git a/docs/sql/order-groups-n8n-insert-access.sql b/docs/sql/order-groups-n8n-insert-access.sql new file mode 100644 index 0000000..5085df1 --- /dev/null +++ b/docs/sql/order-groups-n8n-insert-access.sql @@ -0,0 +1,32 @@ +-- n8n import into public.order_groups +-- +-- Recommended setup: +-- 1. n8n must call Supabase REST with the SERVICE_ROLE key, not the anon key. +-- 2. Keep RLS closed for anon/authenticated inserts unless the request comes +-- from an authenticated application user with a coordination role. +-- +-- n8n HTTP headers for REST inserts: +-- apikey: +-- Authorization: Bearer +-- Content-Type: application/json +-- Prefer: resolution=merge-duplicates,return=representation +-- +-- Endpoint example: +-- POST https://.supabase.co/rest/v1/order_groups +-- +-- Why this is needed: +-- current_role_name() is based on auth.uid() and public.users. A plain n8n +-- anon request has no application user, so insert policies such as +-- current_role_name() in ('manager', 'logistician', 'admin') reject the row. +-- service_role bypasses RLS and is the correct key for trusted server workflows. + +alter table public.order_groups enable row level security; + +drop policy if exists "order groups insert service roles" on public.order_groups; +create policy "order groups insert service roles" on public.order_groups +for insert +with check (public.current_role_name() in ('manager', 'logistician', 'admin')); + +-- Optional diagnostic query: if this returns null for the JWT used by n8n, +-- that JWT is not an app user and cannot pass the application-user RLS policy. +select public.current_role_name() as current_app_role; diff --git a/docs/superpowers/plans/2026-05-11-manual-delivery-agreement.md b/docs/superpowers/plans/2026-05-11-manual-delivery-agreement.md new file mode 100644 index 0000000..7e88e47 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-manual-delivery-agreement.md @@ -0,0 +1,453 @@ +# Manual Delivery Agreement 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:** Make manager/logistician manual delivery agreement safe and clear: only future delivery dates are allowed, controls match the app theme, order group counters are correct, and confusing technical fields are removed from the card. + +**Architecture:** Keep the workflow centered on `order_groups`. The UI validates future dates before submit, the Edge Function enforces the same rule server-side, and the repository maps partial `order_groups` rows into a clean view model for dashboards and cards. + +**Tech Stack:** React 18, Vite, Vitest, Supabase JS, Supabase Edge Functions in Deno, Tailwind utility classes with CSS variables. + +--- + +## File Structure + +- Modify: `src/components/orders/OrderDetailPanel.jsx` + Responsible for the order group detail card, manual agreement UI, local form validation, and hiding confusing technical fields. + +- Modify: `src/components/orders/OrderDetailPanel.test.jsx` + Server-rendered component tests for the detail card, editable controls, and visible copy. + +- Modify: `src/services/supabase/orderGroupRepository.js` + Responsible for mapping raw `order_groups` rows into the frontend delivery group model and saving manual delivery choices through the Edge Function. + +- Modify: `src/services/supabase/orderGroupRepository.test.js` + Mapping tests for missing counters, real delivery dates, and fallback behavior. + +- Modify: `supabase/functions/update-order-group-delivery-choice/index.ts` + Server-side manual agreement validation and update logic. + +- Optional modify: `src/components/UI/Select.jsx` + Only touch if other select controls still need a global design correction after the manual agreement block switches to app-styled buttons. + +- Optional modify: `docs/sql/order-groups-manual-delivery-choice.sql` + Only touch if database constraints are added later. Current requirement can be enforced in the Edge Function. + +--- + +## Chunk 1: Data Mapping Correctness + +### Task 1: Stop Showing Fake Delivery Dates + +**Files:** +- Modify: `src/services/supabase/orderGroupRepository.js` +- Test: `src/services/supabase/orderGroupRepository.test.js` + +- [ ] **Step 1: Write the failing mapping test** + +Add a case where `customer_date` exists but `delivery_date` is null. + +```js +const group = mapOrderGroupRowToDeliveryGroup({ + id: "group-without-delivery-date", + group_key: "9781632663|28.04.26", + customer_date: "28.04.26", + order_numbers: ["СФ Т\\ЕА-26979"], + status: "ready_for_notification", + delivery_status: "pending_confirmation", + created_at: "2026-05-05 09:43:53.750061+00", + updated_at: "2026-05-05 09:43:53.750061+00", +}); + +expect(group.customerDate).toBe("28.04.26"); +expect(group.deliveryDate).toBe(""); +``` + +- [ ] **Step 2: Run the focused test** + +Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js` + +Expected before implementation: FAIL because `deliveryDate` is incorrectly filled from `customerDate`. + +- [ ] **Step 3: Implement the mapping fix** + +In `mapOrderGroupRowToDeliveryGroup`, set: + +```js +const deliveryDate = normalizeText(row.delivery_date); +``` + +Do not fall back to `customerDate` for actual delivery agreement data. + +- [ ] **Step 4: Run the focused test** + +Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js` + +Expected: PASS. + +### Task 2: Infer Counters When `order_groups` Counter Columns Are Empty + +**Files:** +- Modify: `src/services/supabase/orderGroupRepository.js` +- Test: `src/services/supabase/orderGroupRepository.test.js` + +- [ ] **Step 1: Write the failing counter test** + +Use a real-shaped row where `order_numbers` has values, but `orders_count`, `ready_count`, and `not_ready_count` are missing. + +```js +const group = mapOrderGroupRowToDeliveryGroup({ + id: "group-without-counters", + group_key: "9781632663|28.04.26", + order_numbers: ["СФ Т\\ЕА-26979"], + status: "ready_for_notification", + delivery_status: "pending_confirmation", + created_at: "2026-05-05 09:43:53.750061+00", + updated_at: "2026-05-05 09:43:53.750061+00", +}); + +expect(group.ordersCount).toBe(1); +expect(group.readyCount).toBe(1); +expect(group.notReadyCount).toBe(0); +``` + +- [ ] **Step 2: Run the focused test** + +Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js` + +Expected before implementation: FAIL with `0` counters. + +- [ ] **Step 3: Implement fallback counters** + +Use `order_numbers.length` as a fallback for total count. For `status === "ready_for_notification"`, infer `readyCount` as `ordersCount` when explicit ready counters are absent. + +```js +const orderNumbers = toStringArray(row.order_numbers); +const inferredOrderCount = orderNumbers.length; +const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount); +const readyCount = toNumber( + row.ready_count ?? row.orders_ready ?? row.legacy_orders_ready, + row.status === "ready_for_notification" ? ordersCount : 0, +); +const notReadyCount = toNumber( + row.not_ready_count ?? row.orders_not_ready ?? row.legacy_orders_not_ready, + Math.max(ordersCount - readyCount, 0), +); +``` + +- [ ] **Step 4: Run mapping tests** + +Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js` + +Expected: PASS. + +--- + +## Chunk 2: Manual Agreement UI + +### Task 3: Replace Native Date Input With Themed Future-Date Picker + +**Files:** +- Modify: `src/components/orders/OrderDetailPanel.jsx` +- Test: `src/components/orders/OrderDetailPanel.test.jsx` + +- [ ] **Step 1: Add date helper functions** + +Add local helpers near `normalizeDateForInput`: + +```js +const toDateKey = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const addDays = (date, amount) => { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + amount); + return nextDate; +}; + +const getTomorrowDateKey = () => toDateKey(addDays(new Date(), 1)); +const isFutureDeliveryDate = (value) => Boolean(value) && value >= getTomorrowDateKey(); +``` + +- [ ] **Step 2: Default the editable form to tomorrow** + +When the selected group has no valid future `deliveryDate`, initialize the manual form with tomorrow. + +```js +const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate); +setDeliveryDate(isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : getTomorrowDateKey()); +``` + +- [ ] **Step 3: Replace ``** + +Render a styled button that opens a compact 21-day date grid. The grid should use app CSS variables: `--color-card`, `--color-surface`, `--color-border`, `--color-accent`, `--color-accent-soft`, `--color-text`, and `--color-text-muted`. + +- [ ] **Step 4: Test editable controls** + +Update `OrderDetailPanel.test.jsx` so editable markup includes: + +```js +expect(editableMarkup).toContain("Ближайшие даты"); +expect(editableMarkup).toContain("Согласовать"); +``` + +- [ ] **Step 5: Run component test** + +Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx` + +Expected: PASS. + +### Task 4: Replace Native Time Select With Themed Segmented Buttons + +**Files:** +- Modify: `src/components/orders/OrderDetailPanel.jsx` +- Test: `src/components/orders/OrderDetailPanel.test.jsx` + +- [ ] **Step 1: Remove `Select` import from `OrderDetailPanel.jsx`** + +The manual agreement block should no longer use the native dropdown. + +- [ ] **Step 2: Render time options as buttons** + +Use `DELIVERY_TIME_OPTIONS`: + +```js +const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"]; +``` + +Each option should be a `button type="button"` with `aria-pressed={deliveryTime === option}` and selected styling through app CSS variables. + +- [ ] **Step 3: Ensure mobile layout is comfortable** + +Use a responsive grid: + +```jsx +
+``` + +- [ ] **Step 4: Run component test** + +Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx` + +Expected: PASS. + +--- + +## Chunk 3: Validation And Server Enforcement + +### Task 5: Block Today And Past Dates In The UI + +**Files:** +- Modify: `src/components/orders/OrderDetailPanel.jsx` +- Test: `src/components/orders/OrderDetailPanel.test.jsx` + +- [ ] **Step 1: Add submit validation** + +Before calling `onSaveManualDeliveryChoice`, check: + +```js +if (!isFutureDeliveryDate(deliveryDate)) { + setFormMessage("Выберите дату доставки позже сегодняшнего дня."); + return; +} +``` + +- [ ] **Step 2: Add a client-side interaction test if the test setup supports events** + +If this component is only tested with `renderToStaticMarkup`, keep validation covered by a server-side Edge Function test/check instead. Do not add a brittle DOM test just to satisfy coverage. + +- [ ] **Step 3: Run component tests** + +Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx` + +Expected: PASS. + +### Task 6: Enforce Future Dates In Edge Function + +**Files:** +- Modify: `supabase/functions/update-order-group-delivery-choice/index.ts` + +- [ ] **Step 1: Add date comparison helpers** + +```ts +const getTodayKey = () => new Date().toISOString().slice(0, 10); +const isFutureDeliveryDate = (value: string) => isValidDate(value) && value > getTodayKey(); +``` + +- [ ] **Step 2: Replace date validation** + +Change: + +```ts +if (!isValidDate(deliveryDate)) { + return jsonResponse({ ok: false, error: "Valid deliveryDate is required" }, 400, corsHeaders); +} +``` + +to: + +```ts +if (!isFutureDeliveryDate(deliveryDate)) { + return jsonResponse({ ok: false, error: "Future deliveryDate is required" }, 400, corsHeaders); +} +``` + +- [ ] **Step 3: Run Deno check** + +Run: `deno check supabase/functions/update-order-group-delivery-choice/index.ts` + +Expected: PASS. + +- [ ] **Step 4: Deploy function after local verification** + +Run when ready: + +```bash +supabase functions deploy update-order-group-delivery-choice +``` + +Expected: deployed function rejects today/past dates even if someone bypasses the UI. + +--- + +## Chunk 4: Detail Card Cleanup + +### Task 7: Remove Confusing Legacy Fields From The Card + +**Files:** +- Modify: `src/components/orders/OrderDetailPanel.jsx` +- Test: `src/components/orders/OrderDetailPanel.test.jsx` + +- [ ] **Step 1: Change empty value wording** + +Use `Нет данных` for generic missing data instead of `Не указано`, except for binary fields where the user expects `Да` or `Нет`. + +```js +const renderValue = (value) => value || "Нет данных"; +``` + +- [ ] **Step 2: Show SMS as a binary value** + +Change the SMS field: + +```jsx +

{order.smsSentAt ? "Да" : "Нет"}

+``` + +- [ ] **Step 3: Hide legacy customer** + +Remove the visible `Клиент из старых данных` field from the card. + +- [ ] **Step 4: Hide empty technical fields** + +Only render `Создано из обмена` and `Ключ источника` when values exist. Do not show `Нет данных` for these technical fields. + +- [ ] **Step 5: Run component tests** + +Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx` + +Expected: PASS. + +--- + +## Chunk 5: Verification + +### Task 8: Run Focused Tests + +**Files:** +- Test: `src/components/orders/OrderDetailPanel.test.jsx` +- Test: `src/services/supabase/orderGroupRepository.test.js` +- Test: `src/pages/DashboardPage.test.jsx` + +- [ ] **Step 1: Run focused frontend tests** + +Run: + +```bash +npm test -- --run src/components/orders/OrderDetailPanel.test.jsx src/services/supabase/orderGroupRepository.test.js src/pages/DashboardPage.test.jsx +``` + +Expected: PASS. + +- [ ] **Step 2: Run Edge Function type check** + +Run: + +```bash +deno check supabase/functions/update-order-group-delivery-choice/index.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run production build** + +Run: + +```bash +npm run build +``` + +Expected: PASS. + +### Task 9: Manual Browser Verification + +**Files:** +- Manual check: `http://localhost:5174/dashboard` + +- [ ] **Step 1: Open an order group as manager/logistician** + +Expected: card shows order counters based on available orders, not misleading `0/0` when `order_numbers` has values. + +- [ ] **Step 2: Check manual agreement block** + +Expected: date picker starts from tomorrow, not today or an old customer date. + +- [ ] **Step 3: Select date and half-day** + +Expected: controls visually match dark/light theme, no native browser dropdown styling dominates the UI. + +- [ ] **Step 4: Save manual agreement** + +Expected: valid future date saves; today/past date cannot be sent from UI. + +- [ ] **Step 5: Check additional data block** + +Expected: `SMS отправлено` shows `Да` or `Нет`; no `Клиент из старых данных`; empty technical fields are hidden. + +--- + +## Commit Plan + +- [ ] **Commit 1: Data mapping** + +```bash +git add src/services/supabase/orderGroupRepository.js src/services/supabase/orderGroupRepository.test.js +git commit -m "fix(order-groups): normalize delivery group counters" +``` + +- [ ] **Commit 2: Manual agreement UI** + +```bash +git add src/components/orders/OrderDetailPanel.jsx src/components/orders/OrderDetailPanel.test.jsx +git commit -m "feat(order-groups): improve manual delivery agreement" +``` + +- [ ] **Commit 3: Edge Function validation** + +```bash +git add supabase/functions/update-order-group-delivery-choice/index.ts +git commit -m "fix(edge): require future delivery dates" +``` + +--- + +## Rollout Notes + +- The frontend change is immediate after deploy. +- The Edge Function must be deployed separately with `supabase functions deploy update-order-group-delivery-choice`. +- Existing rows with old `delivery_date` values will still contain those dates in the database. This plan prevents new manual agreements from writing today or past dates. +- Temporary open RLS used for testing should be revisited before production hardening. diff --git a/docs/superpowers/plans/2026-05-12-themed-status-dropdown.md b/docs/superpowers/plans/2026-05-12-themed-status-dropdown.md new file mode 100644 index 0000000..9523966 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-themed-status-dropdown.md @@ -0,0 +1,68 @@ +# Themed Status Dropdown 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:** Replace the native status `` With a Custom Dropdown + +**Files:** +- Modify: `src/components/orders/OrderFilters.jsx` + +- [ ] **Step 1: Replace the status ``** + +Assert that the markup no longer contains a native `select` element. + +- [ ] **Step 2: Add a markup assertion for the custom trigger** + +Verify that the closed state still renders the selected label and the status control remains present. + +- [ ] **Step 3: Run the focused test** + +Run: `npm test -- --run src/components/orders/OrderFilters.test.jsx` + +Expected: PASS. + +### Task 3: Build Verification + +**Files:** +- None + +- [ ] **Step 1: Run the production build** + +Run: `npm run build` + +Expected: PASS. diff --git a/src/components/UI/Badge.jsx b/src/components/UI/Badge.jsx index 1c39a30..c702a58 100644 --- a/src/components/UI/Badge.jsx +++ b/src/components/UI/Badge.jsx @@ -1,17 +1,18 @@ import React from "react"; import { cn } from "../../lib/cn"; -export const Badge = ({ children, tone = "neutral" }) => { +export const Badge = ({ children, tone = "neutral", className }) => { return ( {children} diff --git a/src/components/driver/DriverDeliveryPlanner.jsx b/src/components/driver/DriverDeliveryPlanner.jsx index 3e0ea68..24d08bf 100644 --- a/src/components/driver/DriverDeliveryPlanner.jsx +++ b/src/components/driver/DriverDeliveryPlanner.jsx @@ -50,6 +50,9 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => { () => groupOrderGroupsByDate(filteredOrderGroups), [filteredOrderGroups], ); + const deliveryCountLabel = `${filteredOrderGroups.length} ${ + filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок" + }`; return (
@@ -57,12 +60,14 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
-

Мои доставки

+
+

Мои доставки

+ {deliveryCountLabel} +

Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня.

- {filteredOrderGroups.length}
diff --git a/src/components/driver/DriverDeliveryPlanner.test.jsx b/src/components/driver/DriverDeliveryPlanner.test.jsx index 11c2957..dcfb565 100644 --- a/src/components/driver/DriverDeliveryPlanner.test.jsx +++ b/src/components/driver/DriverDeliveryPlanner.test.jsx @@ -51,6 +51,7 @@ describe("DriverDeliveryPlanner", () => { ); expect(markup).toContain("Мои доставки"); + expect(markup).toContain("1 доставка"); expect(markup).toContain("Мария Волкова"); expect(markup).toContain("CD-240031"); expect(markup).not.toContain("Не показывать"); diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index f88adc7..46c01a9 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -2,10 +2,10 @@ import React from "react"; import { buildOrderGroupBuckets, filterOrderGroups, - getOrderGroupStatusLabel, + getOrderGroupDisplayStatusLabel, getOrderGroupStatusTone, ORDER_GROUP_BUCKET_LABELS, - ORDER_GROUP_STATUS_LABELS, + ORDER_GROUP_DISPLAY_STATUS_OPTIONS, } from "../../services/orderGroupViews"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; @@ -17,11 +17,6 @@ const BUCKET_ICONS = { manual_work: "\u26A0", }; -const ORDER_GROUP_STATUS_OPTIONS = [ - { value: "all", label: "Все статусы" }, - ...Object.entries(ORDER_GROUP_STATUS_LABELS).map(([value, label]) => ({ value, label })), -]; - const renderOrderNumbers = (group) => { if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) { return Номера не указаны; @@ -42,7 +37,7 @@ const renderOrderNumbers = (group) => { }; export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => { - const [filters, setFilters] = React.useState({ query: "", status: "all" }); + const [filters, setFilters] = React.useState({ query: "", displayStatus: "all" }); const filteredGroups = React.useMemo( () => filterOrderGroups(orderGroups, filters), @@ -63,9 +58,6 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {

Наборы доставки

-

- Группы из таблицы `order_groups`, разбитые по состоянию готовности. -

{totalGroups} групп
@@ -73,7 +65,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => { @@ -119,19 +111,20 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => { }} type="button" > -
-
+
+
{group.displayTitle || group.customerName || group.groupKey}
-
- {group.customerDate || "—"} · {group.customerPhone || "—"} · {group.ordersCount || 0}{" "} - {group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"} -
-
{renderOrderNumbers(group)}
+ + {getOrderGroupDisplayStatusLabel(group)} +
- - {getOrderGroupStatusLabel(group.status)} +
+ {group.customerDate || "—"} · {group.customerPhone || "—"} · {group.ordersCount || 0}{" "} + {group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"} +
+
{renderOrderNumbers(group)}
))} diff --git a/src/components/logistics/LogisticsReadinessBoard.test.jsx b/src/components/logistics/LogisticsReadinessBoard.test.jsx index d6759d7..16f0fdc 100644 --- a/src/components/logistics/LogisticsReadinessBoard.test.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.test.jsx @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { ORDER_GROUP_BUCKET_LABELS, ORDER_GROUP_STATUS_LABELS } from "../../services/orderGroupViews"; +import { + ORDER_GROUP_BUCKET_LABELS, + ORDER_GROUP_DISPLAY_STATUS_OPTIONS, + ORDER_GROUP_STATUS_LABELS, +} from "../../services/orderGroupViews"; describe("LogisticsReadinessBoard", () => { it("renders all group bucket labels from the model", () => { @@ -20,5 +24,6 @@ describe("LogisticsReadinessBoard", () => { expect(ORDER_GROUP_STATUS_LABELS.ready_for_notification).toBe("Готово к уведомлению"); expect(ORDER_GROUP_STATUS_LABELS.sms_sent).toBe("SMS отправлены"); expect(ORDER_GROUP_STATUS_LABELS.manual_work).toBe("Нужна ручная работа"); + expect(ORDER_GROUP_DISPLAY_STATUS_OPTIONS.map((option) => option.label)).toContain("Согласовано"); }); }); diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index eb65e7c..71eacf1 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -1,7 +1,20 @@ +import React from "react"; import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; +import { Button } from "../UI/Button"; import { Panel } from "../UI/Panel"; -import { getOrderGroupStatusLabel, getOrderGroupStatusTone } from "../../services/orderGroupViews"; +import { + getOrderGroupDeliveryStatusLabel, + getOrderGroupDisplayStatusLabel, + getOrderGroupStatusTone, +} from "../../services/orderGroupViews"; + +const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"]; +const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"]; +const DELIVERY_TIME_ALIASES = { + "До обеда": "Первая половина дня", + "После обеда": "Вторая половина дня", +}; const renderList = (values) => { if (!Array.isArray(values) || !values.length) { @@ -22,9 +35,198 @@ const renderList = (values) => { ); }; -const renderValue = (value) => value || "Не указано"; +const renderValue = (value) => value || "Нет данных"; + +const getErrorMessage = (error, fallbackMessage) => { + if (!error) { + return fallbackMessage; + } + + if (error instanceof Error) { + return error.message || fallbackMessage; + } + + if (typeof error === "string") { + return error || fallbackMessage; + } + + return error?.message || fallbackMessage; +}; + +const normalizeDeliveryTimeChoice = (value) => { + const normalized = value ? String(value).trim() : ""; + const deliveryTime = DELIVERY_TIME_ALIASES[normalized] || normalized; + return DELIVERY_TIME_OPTIONS.includes(deliveryTime) ? deliveryTime : DELIVERY_TIME_OPTIONS[0]; +}; + +const toDateKey = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const fromDateKey = (value) => { + const normalized = normalizeDateForInput(value); + + if (!normalized) { + return null; + } + + const [year, month, day] = normalized.split("-").map(Number); + return new Date(year, month - 1, day); +}; + +const addDays = (date, amount) => { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + amount); + return nextDate; +}; + +const isWeekendDate = (date) => { + const day = date.getDay(); + return day === 0 || day === 6; +}; + +export const getNextSelectableDateKey = (referenceDate = new Date()) => { + let current = addDays(referenceDate, 1); + + while (isWeekendDate(current)) { + current = addDays(current, 1); + } + + return toDateKey(current); +}; + +const isFutureDeliveryDate = (value) => { + const parsedDate = fromDateKey(value); + + if (!parsedDate) { + return false; + } + + return !isWeekendDate(parsedDate) && toDateKey(parsedDate) >= getNextSelectableDateKey(); +}; + +const isSelectableCalendarDate = (date, minDateKey) => { + const dateKey = toDateKey(date); + return dateKey >= minDateKey && !isWeekendDate(date); +}; + +const formatDateForDisplay = (value) => { + if (!value) { + return "Выберите дату"; + } + + const [year, month, day] = value.split("-").map(Number); + if (!year || !month || !day) { + return value; + } + + return new Date(year, month - 1, day).toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +}; + +const formatDeliveryDateDisplay = (value) => { + const normalized = normalizeDateForInput(value); + + if (!normalized) { + return renderValue(value); + } + + return formatDateForDisplay(normalized); +}; + +const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1); + +const addMonths = (date, amount) => new Date(date.getFullYear(), date.getMonth() + amount, 1); + +const buildCalendarDays = (currentMonth) => { + const firstDay = startOfMonth(currentMonth); + const lastDay = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0); + const firstWeekDay = (firstDay.getDay() + 6) % 7; + const totalDays = lastDay.getDate(); + const cells = []; + + for (let index = 0; index < firstWeekDay; index += 1) { + cells.push(null); + } + + for (let day = 1; day <= totalDays; day += 1) { + cells.push(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day)); + } + + while (cells.length % 7 !== 0) { + cells.push(null); + } + + return cells; +}; + +const normalizeDateForInput = (value) => { + if (!value) { + return ""; + } + + const normalized = String(value).trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return normalized; + } + + const shortDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{2})$/); + if (shortDateMatch) { + const [, day, month, year] = shortDateMatch; + return `20${year}-${month}-${day}`; + } + + return ""; +}; + +export const OrderDetailPanel = ({ + order, + canManageDelivery = false, + onSaveManualDeliveryChoice, + isSavingDeliveryChoice = false, +}) => { + const [deliveryDate, setDeliveryDate] = React.useState(""); + const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]); + const [formMessage, setFormMessage] = React.useState(""); + const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); + const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); + const [currentMonth, setCurrentMonth] = React.useState(() => { + const existingDeliveryDate = fromDateKey(order?.deliveryDate); + const fallbackDate = fromDateKey(minSelectableDateKey) || new Date(); + const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate)) + ? existingDeliveryDate + : fallbackDate; + + return startOfMonth(sourceDate); + }); + const calendarDays = React.useMemo(() => buildCalendarDays(currentMonth), [currentMonth]); + const monthLabel = React.useMemo( + () => + currentMonth.toLocaleDateString("ru-RU", { + month: "long", + year: "numeric", + }), + [currentMonth], + ); + const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date())); + + React.useEffect(() => { + const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate); + const nextSelectableDateKey = getNextSelectableDateKey(); + const selectedDateKey = isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : nextSelectableDateKey; + setDeliveryDate(selectedDateKey); + const selectedDate = fromDateKey(selectedDateKey) || new Date(); + setCurrentMonth(startOfMonth(selectedDate)); + setDeliveryTime(normalizeDeliveryTimeChoice(order?.deliveryTime || order?.deliveryHalfDay)); + setFormMessage(""); + }, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime]); -export const OrderDetailPanel = ({ order }) => { if (!order) { return ( @@ -33,6 +235,41 @@ export const OrderDetailPanel = ({ order }) => { ); } + const isDeliveryAgreed = (order.deliveryStatus || order.delivery_status) === "agreed"; + const agreedDeliveryLabel = [ + formatDeliveryDateDisplay(order.deliveryDate), + order.deliveryTime || order.deliveryHalfDay, + ].filter((value) => value && value !== "Нет данных").join(" · "); + + const handleSaveDeliveryChoice = async () => { + if (!deliveryDate || !deliveryTime) { + setFormMessage("Укажите дату и половину дня доставки."); + return; + } + + if (!isFutureDeliveryDate(deliveryDate)) { + setFormMessage("Выберите дату доставки позже сегодняшнего дня."); + return; + } + + try { + const result = await onSaveManualDeliveryChoice?.({ + orderGroupId: order.id, + deliveryDate, + deliveryTime, + }); + + if (result?.success) { + setFormMessage("Доставка согласована вручную."); + return; + } + + setFormMessage(getErrorMessage(result?.error, "Не удалось сохранить согласование доставки.")); + } catch (error) { + setFormMessage(getErrorMessage(error, "Не удалось сохранить согласование доставки.")); + } + }; + return (
@@ -48,7 +285,22 @@ export const OrderDetailPanel = ({ order }) => { {order.displaySubtitle || [order.customerPhone, order.customerDate].filter(Boolean).join(" · ") || "Не указано"}

- {getOrderGroupStatusLabel(order.status)} + {getOrderGroupDisplayStatusLabel(order)} +
+ +
+
+

+ Дата доставки +

+

{formatDeliveryDateDisplay(order.deliveryDate)}

+
+
+

+ Время доставки +

+

{renderValue(order.deliveryTime || order.deliveryHalfDay)}

+
@@ -68,6 +320,10 @@ export const OrderDetailPanel = ({ order }) => {

Дата

{renderValue(order.customerDate)}

+
+

Адрес доставки

+

{renderValue(order.deliveryAddress)}

+

Всего заказов

{order.ordersCount ?? 0}

@@ -84,9 +340,182 @@ export const OrderDetailPanel = ({ order }) => {

Обновлена

{formatDateTime(order.updatedAt)}

+
+

Статус доставки

+

{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}

+
+ {canManageDelivery ? ( + +
+ Ручное согласование доставки +

+ {isDeliveryAgreed + ? "Дата и половина дня доставки уже зафиксированы." + : "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."} +

+
+ {isDeliveryAgreed ? ( +
+
+
+

+ Доставка согласована +

+

+ {agreedDeliveryLabel || "Дата и время сохранены"} +

+
+ Согласовано +
+
+ ) : ( +
+
+ + {isCalendarOpen ? ( +
+
+
+

+ Календарь доставки +

+

+ {monthLabel} +

+
+
+ + +
+
+
+ {WEEK_DAY_LABELS.map((day) => ( +
+ {day} +
+ ))} +
+
+ {calendarDays.map((day, index) => { + if (!day) { + return
; + } + + const dateKey = toDateKey(day); + const isWeekend = isWeekendDate(day); + const isSelectable = isSelectableCalendarDate(day, minSelectableDateKey); + const isSelected = dateKey === deliveryDate; + const isDisabled = !isSelectable; + const dayNumber = String(day.getDate()).padStart(2, "0"); + + return ( + + ); + })} +
+

+ Выходные отмечены пунктиром и недоступны. +

+
+ ) : null} +
+
+ {DELIVERY_TIME_OPTIONS.map((option) => ( + + ))} +
+ +
+ )} + {formMessage ? ( +

{formMessage}

+ ) : null} + + ) : null} + Номера заказов {renderList(order.orderNumbers)} @@ -97,31 +526,22 @@ export const OrderDetailPanel = ({ order }) => {

SMS отправлено

-

{renderValue(formatDateTime(order.smsSentAt))}

-
-
-

Создано из обмена

-

{renderValue(formatDateTime(order.createdFromExchangeAt))}

-
-
-

Source key

-

{renderValue(order.sourceKey)}

-
-
-

Legacy customer

-

{renderValue(order.legacyCustomerName)}

+

{order.smsSentAt ? "Да" : "Нет"}

+ {order.createdFromExchangeAt ? ( +
+

Создано из обмена

+

{formatDateTime(order.createdFromExchangeAt)}

+
+ ) : null} + {order.sourceKey ? ( +
+

Ключ источника

+

{order.sourceKey}

+
+ ) : null}
- - {order.sourceOrders ? ( - - Source orders -
-            {JSON.stringify(order.sourceOrders, null, 2)}
-          
-
- ) : null}
); }; diff --git a/src/components/orders/OrderDetailPanel.test.jsx b/src/components/orders/OrderDetailPanel.test.jsx index 8afe509..480c323 100644 --- a/src/components/orders/OrderDetailPanel.test.jsx +++ b/src/components/orders/OrderDetailPanel.test.jsx @@ -1,7 +1,7 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { OrderDetailPanel } from "./OrderDetailPanel"; +import { OrderDetailPanel, getNextSelectableDateKey } from "./OrderDetailPanel"; const order = { id: "o-1", @@ -11,6 +11,7 @@ const order = { customerName: "Мария Волкова", customerPhone: "+7 978 000-12-31", customerDate: "16.04.26", + deliveryAddress: "Симферополь, ул. Ленина, 10", ordersCount: 1, readyCount: 1, notReadyCount: 0, @@ -23,6 +24,9 @@ const order = { sourceOrders: null, createdAt: "2026-03-15T08:00:00Z", updatedAt: "2026-03-16T09:00:00Z", + deliveryStatus: "pending_confirmation", + deliveryDate: "2026-05-18", + deliveryTime: "Первая половина дня", customer: { name: "Мария Волкова", phone: "+7 978 000-12-31", @@ -38,6 +42,12 @@ describe("OrderDetailPanel", () => { expect(markup).toContain("Карточка группы доставки"); expect(markup).toContain("Мария Волкова"); + expect(markup).toContain("Адрес доставки"); + expect(markup).toContain("Симферополь, ул. Ленина, 10"); + expect(markup).toContain("Дата доставки"); + expect(markup).toContain("18.05.2026"); + expect(markup).toContain("Время доставки"); + expect(markup).toContain("Первая половина дня"); expect(markup).toContain("CD-240031"); expect(markup).toContain("Готово"); }); @@ -51,11 +61,12 @@ describe("OrderDetailPanel", () => { updatedAt: "broken-date", smsSentAt: null, createdFromExchangeAt: null, + deliveryAddress: "", }} />, ); - expect(markup).toContain("Не указано"); + expect(markup).toContain("Нет данных"); }); it("renders order numbers as chips", () => { @@ -63,4 +74,39 @@ describe("OrderDetailPanel", () => { expect(markup).toContain("CD-240031"); }); + + it("shows manual delivery controls only for editable cards", () => { + const editableMarkup = renderToStaticMarkup( + {}} />, + ); + const readonlyMarkup = renderToStaticMarkup(); + + expect(editableMarkup).toContain("Ручное согласование доставки"); + expect(editableMarkup).toContain("Согласовать"); + expect(editableMarkup).toContain("Выберите дату"); + expect(readonlyMarkup).not.toContain("Ручное согласование доставки"); + }); + + it("shows an explicit agreed delivery state in the manual agreement area", () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain("Доставка согласована"); + expect(markup).toContain("18.05.2026 · Первая половина дня"); + expect(markup).not.toContain("Согласовать"); + }); + + it("skips weekends when selecting the default manual delivery date", () => { + expect(getNextSelectableDateKey(new Date("2026-05-15T12:00:00Z"))).toBe("2026-05-18"); + }); }); diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index 33e4647..c50e80c 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -1,40 +1,111 @@ +import React from "react"; import { Badge } from "../UI/Badge"; import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => { - const selectedStatusLabel = statusOptions.find((option) => option.value === filters.status)?.label || filters.status; + const statusValue = filters.displayStatus || filters.status || "all"; + const selectedStatusLabel = statusOptions.find((option) => option.value === statusValue)?.label || statusValue; + const [isStatusOpen, setIsStatusOpen] = React.useState(false); + const statusMenuRef = React.useRef(null); + + React.useEffect(() => { + if (!isStatusOpen) { + return undefined; + } + + const handlePointerDown = (event) => { + if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) { + setIsStatusOpen(false); + } + }; + + const handleKeyDown = (event) => { + if (event.key === "Escape") { + setIsStatusOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isStatusOpen]); + const updateFilter = (key, value) => { setFilters((current) => ({ ...current, [key]: value })); }; - const activeChips = [filters.status !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean); + const activeChips = [statusValue !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean); return ( -
+
updateFilter("query", event.target.value)} /> -
{activeChips.length ? ( diff --git a/src/components/orders/OrderFilters.test.jsx b/src/components/orders/OrderFilters.test.jsx index f600da1..7fafa83 100644 --- a/src/components/orders/OrderFilters.test.jsx +++ b/src/components/orders/OrderFilters.test.jsx @@ -9,19 +9,21 @@ describe("OrderFilters", () => { {}} statusOptions={[ { value: "all", label: "Все статусы" }, - { value: "ready_for_notification", label: "Готовы к уведомлению" }, + { value: "status:ready_for_notification", label: "Готово к уведомлению" }, + { value: "delivery:agreed", label: "Согласовано" }, ]} />, ); expect(markup).toContain("Поиск по группе, клиенту или телефону"); expect(markup).toContain("Статус"); - expect(markup).toContain(" { {}} statusOptions={[ { value: "all", label: "Все статусы" }, - { value: "ready_for_notification", label: "Готовы к уведомлению" }, + { value: "status:ready_for_notification", label: "Готово к уведомлению" }, + { value: "delivery:agreed", label: "Согласовано" }, ]} />, ); - expect(markup).toContain("Готовы к уведомлению"); + expect(markup).toContain("Согласовано"); expect(markup).not.toContain("Активные фильтры"); }); }); diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index befa022..eda78f0 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -2,7 +2,10 @@ import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; import { OrderFilters } from "./OrderFilters"; -import { getOrderGroupStatusLabel, getOrderGroupStatusTone } from "../../services/orderGroupViews"; +import { + getOrderGroupDisplayStatusLabel, + getOrderGroupStatusTone, +} from "../../services/orderGroupViews"; const buildGroupSummary = (group) => { const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`; @@ -61,14 +64,18 @@ export const OrdersTable = ({ selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "", ].join(" ")} > -
-
-
{group.displayTitle || group.customerName || group.groupKey}
-
- {group.displaySubtitle || [group.customerPhone, group.customerDate].filter(Boolean).join(" · ")} +
+
+
+ {group.displayTitle || group.customerName || group.groupKey}
+ + {getOrderGroupDisplayStatusLabel(group)} + +
+
+ {group.displaySubtitle || [group.customerPhone, group.customerDate].filter(Boolean).join(" · ")}
- {getOrderGroupStatusLabel(group.status)}
{buildGroupSummary(group)}
@@ -121,7 +128,7 @@ export const OrdersTable = ({ {renderOrderNumbers(group)} - {getOrderGroupStatusLabel(group.status)} + {getOrderGroupDisplayStatusLabel(group)} {group.readyCount || 0}/{group.ordersCount || 0} diff --git a/src/components/orders/OrdersTable.test.jsx b/src/components/orders/OrdersTable.test.jsx index 8e24214..0d1de74 100644 --- a/src/components/orders/OrdersTable.test.jsx +++ b/src/components/orders/OrdersTable.test.jsx @@ -32,7 +32,8 @@ describe("OrdersTable", () => { setFilters={() => {}} statusOptions={[ { value: "all", label: "Все статусы" }, - { value: "ready_for_notification", label: "ready_for_notification" }, + { value: "status:ready_for_notification", label: "Готово к уведомлению" }, + { value: "delivery:agreed", label: "Согласовано" }, ]} />, ); diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 381e671..6334928 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -16,6 +16,11 @@ const UNKNOWN_EMAIL_ERROR_PATTERNS = [ /sign up is disabled/i, ]; +const STALE_REFRESH_TOKEN_PATTERNS = [ + /invalid refresh token/i, + /refresh token not found/i, +]; + export const normalizeOtpError = (error) => { const message = error instanceof Error ? error.message : String(error || ""); if (UNKNOWN_EMAIL_ERROR_PATTERNS.some((pattern) => pattern.test(message))) { @@ -25,6 +30,11 @@ export const normalizeOtpError = (error) => { return error instanceof Error ? error : new Error(message || PROFILE_LOAD_ERROR); }; +export const isStaleRefreshTokenError = (error) => { + const message = error instanceof Error ? error.message : String(error || ""); + return STALE_REFRESH_TOKEN_PATTERNS.some((pattern) => pattern.test(message)); +}; + export const buildOtpRequestPayload = (email) => ({ email, options: { @@ -102,6 +112,19 @@ export const AuthProvider = ({ children }) => { setAuthError(""); }); + supabase.auth.getSession().then(({ data, error }) => { + if (error && isStaleRefreshTokenError(error)) { + setUser(null); + setAuthError("Сессия истекла. Войдите заново."); + void supabase.auth.signOut({ scope: "local" }); + return; + } + + if (data.session?.user) { + setUser(mapSessionUserToAuthUser(data.session.user)); + } + }); + return () => subscription.unsubscribe(); }, []); diff --git a/src/context/AuthContext.test.js b/src/context/AuthContext.test.js index 8f6d9eb..fed8a9c 100644 --- a/src/context/AuthContext.test.js +++ b/src/context/AuthContext.test.js @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { UNKNOWN_EMAIL_ERROR, buildOtpRequestPayload, + isStaleRefreshTokenError, mapProfileToAuthUser, mapSessionUserToAuthUser, normalizeOtpError, @@ -95,3 +96,13 @@ describe("normalizeOtpError", () => { expect(normalizeOtpError(new Error("Network error")).message).toBe("Network error"); }); }); + +describe("isStaleRefreshTokenError", () => { + it("detects missing refresh tokens from Supabase", () => { + expect(isStaleRefreshTokenError(new Error("Invalid Refresh Token: Refresh Token Not Found"))).toBe(true); + }); + + it("ignores unrelated errors", () => { + expect(isStaleRefreshTokenError(new Error("Network error"))).toBe(false); + }); +}); diff --git a/src/hooks/useOrderGroups.js b/src/hooks/useOrderGroups.js index 34d9b8f..8c34a5b 100644 --- a/src/hooks/useOrderGroups.js +++ b/src/hooks/useOrderGroups.js @@ -1,29 +1,46 @@ import React from "react"; import { demoOrderGroups } from "../data/mockAppData"; -import { fetchOrderGroups } from "../services/supabase/orderGroupRepository"; +import { fetchOrderGroups, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository"; import { buildOrderGroupBuckets, filterOrderGroups, groupOrderGroupsByDate, - getOrderGroupStatusLabel, + ORDER_GROUP_DISPLAY_STATUS_OPTIONS, } from "../services/orderGroupViews"; import { hasSupabaseConfig } from "../supabaseClient"; const cloneLiveGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => ({ ...group })) : []); +const getErrorMessage = (error, fallbackMessage) => { + if (!error) { + return fallbackMessage; + } + + if (error instanceof Error) { + return error.message || fallbackMessage; + } + + if (typeof error === "string") { + return error || fallbackMessage; + } + + return error?.message || fallbackMessage; +}; + export const useOrderGroups = () => { const [orderGroups, setOrderGroups] = React.useState(() => hasSupabaseConfig ? [] : cloneLiveGroups(demoOrderGroups), ); const [filters, setFilters] = React.useState({ query: "", - status: "all", + displayStatus: "all", }); const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(() => hasSupabaseConfig ? null : demoOrderGroups[0]?.id ?? null, ); const [isLoading, setIsLoading] = React.useState(hasSupabaseConfig); const [loadError, setLoadError] = React.useState(""); + const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false); React.useEffect(() => { let cancelled = false; @@ -74,16 +91,7 @@ export const useOrderGroups = () => { } }, [orderGroups, selectedOrderGroupId]); - const statusOptions = React.useMemo(() => { - const statuses = Array.from(new Set(orderGroups.map((group) => group.status).filter(Boolean))).sort((left, right) => - left.localeCompare(right), - ); - - return [ - { value: "all", label: "Все статусы" }, - ...statuses.map((status) => ({ value: status, label: getOrderGroupStatusLabel(status) })), - ]; - }, [orderGroups]); + const statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS; const filteredOrderGroups = React.useMemo( () => filterOrderGroups(orderGroups, filters), @@ -100,6 +108,61 @@ export const useOrderGroups = () => { const orderGroupsByDate = React.useMemo(() => groupOrderGroupsByDate(orderGroups), [orderGroups]); const deliveryGroupBuckets = React.useMemo(() => buildOrderGroupBuckets(orderGroups), [orderGroups]); + const saveManualDeliveryChoice = React.useCallback(async ({ + orderGroupId, + deliveryDate, + deliveryTime, + }) => { + setIsSavingDeliveryChoice(true); + + try { + if (!hasSupabaseConfig) { + const updatedAt = new Date().toISOString(); + setOrderGroups((currentGroups) => + currentGroups.map((group) => + group.id === orderGroupId + ? { + ...group, + deliveryStatus: "agreed", + delivery_status: "agreed", + deliveryDate, + deliveryTime, + deliveryHalfDay: deliveryTime, + updatedAt, + } + : group, + ), + ); + return { success: true }; + } + + const result = await updateOrderGroupDeliveryChoice({ + orderGroupId, + deliveryDate, + deliveryTime, + }); + + if (result.error) { + return { + success: false, + error: getErrorMessage(result.error, "Не удалось сохранить согласование доставки"), + }; + } + + setOrderGroups((currentGroups) => + currentGroups.map((group) => (group.id === orderGroupId ? result.data : group)), + ); + return { success: true, data: result.data }; + } catch (error) { + return { + success: false, + error: getErrorMessage(error, "Не удалось сохранить согласование доставки"), + }; + } finally { + setIsSavingDeliveryChoice(false); + } + }, []); + return { orderGroups, allOrderGroups: orderGroups, @@ -113,6 +176,8 @@ export const useOrderGroups = () => { statusOptions, orderGroupsByDate, deliveryGroupBuckets, + saveManualDeliveryChoice, + isSavingDeliveryChoice, isLoading, loadError, }; diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 5f7cbed..883f492 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -15,7 +15,7 @@ export const groupSlotsFromInvitation = (invitation) => { return []; } - const rawSlots = invitation.availableSlots || []; + const rawSlots = Array.isArray(invitation.availableSlots) ? invitation.availableSlots : []; const deliveryDate = invitation.deliveryDate; const deliveryTime = invitation.deliveryTime; @@ -33,21 +33,47 @@ export const groupSlotsFromInvitation = (invitation) => { ]; } - return rawSlots.map((raw, index) => { - const parts = raw.split(","); - const datePart = parts[0]?.trim() || ""; - const timePart = parts.slice(1).join(",").trim() || ""; + return rawSlots + .map((raw, index) => { + if (typeof raw === "string") { + const parts = raw.split(","); + const datePart = parts[0]?.trim() || ""; + const timePart = parts.slice(1).join(",").trim() || ""; - const parsedDate = datePart.replace(/[а-яё]+/gi, "").trim() - || deliveryDate - || ""; + const parsedDate = datePart.replace(/[а-яё]+/gi, "").trim() + || deliveryDate + || ""; - return { - id: `slot-${index}-${raw}`, - date: parsedDate || deliveryDate || "", - time: timePart || deliveryTime || raw, - }; - }); + return { + id: `slot-${index}-${raw}`, + date: parsedDate || deliveryDate || "", + time: timePart || deliveryTime || raw, + }; + } + + if (raw && typeof raw === "object") { + const slotId = typeof raw.id === "string" ? raw.id : `slot-${index}-${deliveryDate || "custom"}`; + const slotDate = typeof raw.date === "string" ? raw.date : deliveryDate || ""; + const slotTime = typeof raw.time === "string" + ? raw.time + : typeof raw.label === "string" + ? raw.label + : deliveryTime || ""; + + if (!slotDate && !slotTime) { + return null; + } + + return { + id: slotId, + date: slotDate, + time: slotTime, + }; + } + + return null; + }) + .filter(Boolean); }; export const buildDeliveryConfirmationPayload = ({ @@ -128,18 +154,12 @@ export const ClientDeliveryPage = () => { }; }, [token]); - const slots = React.useMemo( - () => groupSlotsFromInvitation(invitation), - [invitation], - ); + const slots = groupSlotsFromInvitation(invitation); const invitationState = invitation?.state || "awaiting_choice"; const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); - const invitationSelectedSlot = React.useMemo( - () => (isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots)), - [invitation, slots, isActiveState], - ); + const invitationSelectedSlot = isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots); const effectiveSelectedSlot = selectedSlot || invitationSelectedSlot; const isChoiceSaved = choiceSaved || (!isActiveState && Boolean(invitationSelectedSlot)); @@ -147,56 +167,50 @@ export const ClientDeliveryPage = () => { ? `${formatDeliveryDate(effectiveSelectedSlot.date)} / ${effectiveSelectedSlot.time}` : ""; - const handleSaveChoice = React.useCallback( - async () => { - if (!token) { - return; - } + const handleSaveChoice = async () => { + if (!token) { + return; + } - if (!effectiveSelectedSlot) { - setError("Сначала выберите дату и половину дня."); - return; - } + if (!effectiveSelectedSlot) { + setError("Сначала выберите дату и половину дня."); + return; + } - setActionMessage("Сохраняем выбор..."); - setChoiceSaved(false); - setError(""); + setActionMessage("Сохраняем выбор..."); + setChoiceSaved(false); + setError(""); - try { - await confirmDeliveryChoice({ - token, - deliveryTime: effectiveSelectedSlot.time, - deliveryDate: effectiveSelectedSlot.date, - }); - const loadedInvitation = await fetchDeliveryInvitation(token); - setInvitation(loadedInvitation); - setSelectedSlot(buildSelectedSlotFromInvitation(loadedInvitation, groupSlotsFromInvitation(loadedInvitation)) || effectiveSelectedSlot); - setChoiceSaved(true); - setActionMessage("Выбор сохранен, спасибо."); - } catch (confirmError) { - setActionMessage(""); - setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор"); - } - }, - [effectiveSelectedSlot, token], - ); + try { + await confirmDeliveryChoice({ + token, + deliveryTime: effectiveSelectedSlot.time, + deliveryDate: effectiveSelectedSlot.date, + }); + const loadedInvitation = await fetchDeliveryInvitation(token); + setInvitation(loadedInvitation); + setSelectedSlot(buildSelectedSlotFromInvitation(loadedInvitation, groupSlotsFromInvitation(loadedInvitation)) || effectiveSelectedSlot); + setChoiceSaved(true); + setActionMessage("Выбор сохранен, спасибо."); + } catch (confirmError) { + setActionMessage(""); + setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор"); + } + }; - const handleSlotSelect = React.useCallback( - (slot) => { - setSelectedSlotId(slot.id); - setSelectedSlot(slot); - setChoiceSaved(false); - setActionMessage( - `Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`, - ); - setError(""); - }, - [], - ); + const handleSlotSelect = (slot) => { + setSelectedSlotId(slot.id); + setSelectedSlot(slot); + setChoiceSaved(false); + setActionMessage( + `Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`, + ); + setError(""); + }; - const handleRequestNewLink = React.useCallback(() => { + const handleRequestNewLink = () => { setActionMessage("Если ссылка больше не работает, логист передаст новую ссылку вручную."); - }, []); + }; if (loading) { return ( diff --git a/src/pages/ClientDeliveryPage.test.js b/src/pages/ClientDeliveryPage.test.js index 808f4eb..bdd818e 100644 --- a/src/pages/ClientDeliveryPage.test.js +++ b/src/pages/ClientDeliveryPage.test.js @@ -104,4 +104,23 @@ describe("ClientDeliveryPage helpers", () => { time: "После обеда", }); }); + + it("ignores malformed slots and keeps object-based slots usable", () => { + expect( + groupSlotsFromInvitation({ + deliveryDate: "2026-04-15", + availableSlots: [ + null, + { id: "slot-object", date: "2026-04-15", time: "Первая половина дня" }, + 42, + ], + }), + ).toEqual([ + { + id: "slot-object", + date: "2026-04-15", + time: "Первая половина дня", + }, + ]); + }); }); diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index f0c0313..3916c08 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -49,6 +49,8 @@ export const DashboardPage = () => { statusOptions, isLoading, loadError, + saveManualDeliveryChoice, + isSavingDeliveryChoice, } = useOrderGroups(); React.useEffect(() => { @@ -153,7 +155,6 @@ export const DashboardPage = () => {

Карточка группы доставки

-

Все данные из таблицы `order_groups`.

- +
diff --git a/src/pages/DashboardPage.test.jsx b/src/pages/DashboardPage.test.jsx index 6d35b6f..7f47832 100644 --- a/src/pages/DashboardPage.test.jsx +++ b/src/pages/DashboardPage.test.jsx @@ -42,6 +42,10 @@ const baseGroup = { notReadyCount: 0, orderNumbers: ["CD-240031"], status: "ready_for_notification", + deliveryStatus: "agreed", + delivery_status: "agreed", + deliveryDate: "2026-04-16", + deliveryTime: "Первая половина дня", updatedAt: "2026-04-15T09:00:00Z", }; @@ -55,7 +59,7 @@ const mockOrderGroupsState = { setSelectedOrderGroupId: vi.fn(), filters: { query: "", - status: "all", + displayStatus: "all", }, setFilters: vi.fn(), deliverySetBuckets: { @@ -65,10 +69,13 @@ const mockOrderGroupsState = { }, statusOptions: [ { value: "all", label: "Все статусы" }, - { value: "ready_for_notification", label: "ready_for_notification" }, + { value: "status:ready_for_notification", label: "Готово к уведомлению" }, + { value: "delivery:agreed", label: "Согласовано" }, ], isLoading: false, loadError: "", + saveManualDeliveryChoice: vi.fn(), + isSavingDeliveryChoice: false, }; describe("DashboardPage", () => { diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index 3d7a309..d2b1b24 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -224,6 +224,7 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime export const requestDeliveryLink = async ({ orderId, + orderGroupId, orderNumber, customerName, customerPhone, @@ -232,6 +233,7 @@ export const requestDeliveryLink = async ({ }) => invokeDeliveryFunction("create-delivery-invitation", { orderId, + orderGroupId, orderNumber, customerName, customerPhone, diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index 0a345ec..7c323ec 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -126,6 +126,26 @@ export const isOrderGroupAgreedForDelivery = (group) => { export const getOrderGroupDeliveryStatusLabel = (status) => DELIVERY_GROUP_STATUS_LABELS[status] || status || "Неизвестно"; +export const getOrderGroupDisplayStatusLabel = (group) => { + const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + + if (deliveryStatus && deliveryStatus !== "pending_confirmation") { + return getOrderGroupDeliveryStatusLabel(deliveryStatus); + } + + return getOrderGroupStatusLabel(group?.status); +}; + +export const getOrderGroupDisplayStatusValue = (group) => { + const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + + if (deliveryStatus && deliveryStatus !== "pending_confirmation") { + return `delivery:${deliveryStatus}`; + } + + return `status:${group?.status || "unknown"}`; +}; + export const isOrderGroupVisibleToDriver = (group) => { const deliveryStatus = group?.deliveryStatus || group?.delivery_status || "pending_confirmation"; return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus); @@ -156,6 +176,7 @@ const parseGroupDate = (value) => { export const filterOrderGroups = (groups, filters = {}) => { const query = normalizeDate(filters.query).trim().toLowerCase(); const status = filters.status || "all"; + const displayStatus = normalizeDate(filters.displayStatus || "all"); const deliveryStatus = normalizeDate(filters.deliveryStatus || "all"); const dateFrom = normalizeDate(filters.dateFrom); const dateTo = normalizeDate(filters.dateTo); @@ -208,6 +229,10 @@ export const filterOrderGroups = (groups, filters = {}) => { return false; } + if (displayStatus !== "all" && getOrderGroupDisplayStatusValue(group) !== displayStatus) { + return false; + } + if (deliveryStatus !== "all") { const groupDeliveryStatus = group.deliveryStatus || group.delivery_status || "pending_confirmation"; @@ -246,10 +271,28 @@ export const ORDER_GROUP_STATUS_LABELS = { manual_work: "Нужна ручная работа", }; +export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [ + { value: "all", label: "Все статусы" }, + { value: "status:ready_for_notification", label: ORDER_GROUP_STATUS_LABELS.ready_for_notification }, + { value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed }, + { value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned }, + { value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded }, + { value: "delivery:on_route", label: DELIVERY_GROUP_STATUS_LABELS.on_route }, + { value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered }, + { value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem }, + { value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled }, + { value: "status:sms_sent", label: ORDER_GROUP_STATUS_LABELS.sms_sent }, + { value: "status:manual_work", label: ORDER_GROUP_STATUS_LABELS.manual_work }, +]; + export const getOrderGroupStatusLabel = (status) => ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно"; export const getOrderGroupDeliveryStatusTone = (status) => { + if (status === "agreed") { + return "accent"; + } + if (status === "problem") { return "warning"; } @@ -348,6 +391,12 @@ export const buildOrderGroupBuckets = (groups) => { }; export const getOrderGroupStatusTone = (group) => { + const deliveryStatus = group?.deliveryStatus || group?.delivery_status; + + if (deliveryStatus && deliveryStatus !== "pending_confirmation") { + return getOrderGroupDeliveryStatusTone(deliveryStatus); + } + if (group.smsSentAt) { return "accent"; } diff --git a/src/services/orderGroupViews.test.js b/src/services/orderGroupViews.test.js index 91dd338..2b56e39 100644 --- a/src/services/orderGroupViews.test.js +++ b/src/services/orderGroupViews.test.js @@ -2,8 +2,12 @@ import { describe, expect, it } from "vitest"; import { buildOrderGroupBuckets, filterOrderGroups, + getOrderGroupDisplayStatusLabel, + getOrderGroupDisplayStatusValue, getOrderGroupDeliveryStatusLabel, getOrderGroupDeliveryHalfDay, + getOrderGroupStatusTone, + ORDER_GROUP_DISPLAY_STATUS_OPTIONS, groupOrderGroupsByDate, isOrderGroupAgreedForDelivery, isOrderGroupVisibleToDriver, @@ -95,4 +99,20 @@ describe("orderGroupViews", () => { expect(filtered).toHaveLength(1); expect(filtered[0].id).toBe("g-1"); }); + + it("shows agreed delivery status as the visible card status", () => { + expect(getOrderGroupDisplayStatusLabel(groups[0])).toBe("Согласовано"); + expect(getOrderGroupDisplayStatusValue(groups[0])).toBe("delivery:agreed"); + expect(getOrderGroupStatusTone(groups[0])).toBe("accent"); + expect(getOrderGroupDisplayStatusLabel(groups[2])).toBe("draft"); + }); + + it("filters by the visible card status", () => { + const agreedGroups = filterOrderGroups(groups, { displayStatus: "delivery:agreed" }); + const deliveredGroups = filterOrderGroups(groups, { displayStatus: "delivery:delivered" }); + + expect(ORDER_GROUP_DISPLAY_STATUS_OPTIONS.map((option) => option.label)).toContain("Согласовано"); + expect(agreedGroups.map((group) => group.id)).toEqual(["g-1"]); + expect(deliveredGroups.map((group) => group.id)).toEqual(["g-2"]); + }); }); diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index c7dae16..ee486d3 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -56,11 +56,21 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { const customerName = normalizeText(row.customer_name || row.legacy_customer_name); const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone); const customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date); - const ordersCount = toNumber(row.orders_count ?? row.legacy_orders_total, 0); - const readyCount = toNumber(row.ready_count ?? row.legacy_orders_ready, 0); - const notReadyCount = toNumber(row.not_ready_count ?? row.legacy_orders_not_ready, 0); const orderNumbers = toStringArray(row.order_numbers); + const inferredOrderCount = orderNumbers.length; + const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount); + const readyCount = toNumber( + row.ready_count ?? row.orders_ready ?? row.legacy_orders_ready, + row.status === "ready_for_notification" ? ordersCount : 0, + ); + const notReadyCount = toNumber( + row.not_ready_count ?? row.orders_not_ready ?? row.legacy_orders_not_ready, + Math.max(ordersCount - readyCount, 0), + ); const deliveryStatus = normalizeText(row.delivery_status) || "pending_confirmation"; + const deliveryDate = normalizeText(row.delivery_date); + const deliveryTime = normalizeText(row.delivery_time); + const deliveryAddress = normalizeText(row.delivery_address); return { id: row.id, @@ -78,6 +88,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { customerPhone, customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone), customerDate, + deliveryAddress, ordersCount, readyCount, notReadyCount, @@ -100,10 +111,11 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { delivery_status: deliveryStatus, displayTitle: customerName || `Группа ${row.group_key || row.id}`, displaySubtitle: [customerPhone, customerDate].filter(Boolean).join(" · ") || row.group_key || row.id, - deliveryDate: customerDate, + deliveryDate, + deliveryTime, deliveryHalfDay: getOrderGroupDeliveryHalfDay({ deliveryHalfDay: row.delivery_half_day, - deliveryTime: row.delivery_time, + deliveryTime, deliveryWindow: row.delivery_window, sourceOrders: row.source_orders, }), @@ -113,8 +125,9 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { customerName, customerPhone, customerDate, + deliveryAddress, row.delivery_half_day, - row.delivery_time, + deliveryTime, row.delivery_window, deliveryStatus, getOrderGroupDeliveryStatusLabel(deliveryStatus), @@ -123,7 +136,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { getOrderGroupStatusLabel(row.status), getOrderGroupDeliveryHalfDay({ deliveryHalfDay: row.delivery_half_day, - deliveryTime: row.delivery_time, + deliveryTime, deliveryWindow: row.delivery_window, sourceOrders: row.source_orders, }), @@ -135,6 +148,34 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => { }; }; +export const updateOrderGroupDeliveryChoice = async ({ + orderGroupId, + deliveryDate, + deliveryTime, +}) => { + return safeSupabaseCall(async () => { + const client = requireSupabase(); + const { data, error } = await client + .from("order_groups") + .update({ + delivery_status: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + notification_status: "confirmed", + updated_at: new Date().toISOString(), + }) + .eq("id", orderGroupId) + .select("*") + .single(); + + if (error) { + throw error; + } + + return mapOrderGroupRowToDeliveryGroup(data); + }, "Ошибка сохранения согласования доставки"); +}; + export const fetchOrderGroups = async () => { return safeSupabaseCall(async () => { const client = requireSupabase(); diff --git a/src/services/supabase/orderGroupRepository.test.js b/src/services/supabase/orderGroupRepository.test.js index f5ade96..274e095 100644 --- a/src/services/supabase/orderGroupRepository.test.js +++ b/src/services/supabase/orderGroupRepository.test.js @@ -1,5 +1,24 @@ -import { describe, expect, it } from "vitest"; -import { mapOrderGroupRowToDeliveryGroup } from "./orderGroupRepository"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { fromMock, updateMock, eqMock, selectMock, singleMock } = vi.hoisted(() => ({ + fromMock: vi.fn(), + updateMock: vi.fn(), + eqMock: vi.fn(), + selectMock: vi.fn(), + singleMock: vi.fn(), +})); + +vi.mock("../../supabaseClient", () => ({ + hasSupabaseConfig: true, + supabase: { + from: fromMock, + }, +})); + +import { + mapOrderGroupRowToDeliveryGroup, + updateOrderGroupDeliveryChoice, +} from "./orderGroupRepository"; describe("mapOrderGroupRowToDeliveryGroup", () => { it("normalizes the order_groups row into a delivery-group view model", () => { @@ -9,12 +28,14 @@ describe("mapOrderGroupRowToDeliveryGroup", () => { customer_name: "Калинина Дарья Егоровна", customer_phone: "3939375462", customer_date: "14.04.26", + delivery_address: "Симферополь, ул. Ленина, 10", orders_count: 1, ready_count: 1, not_ready_count: 0, order_numbers: ["СФ Т\\ЕА-23094"], status: "ready_for_notification", delivery_status: "agreed", + delivery_date: "2026-04-16", sms_sent_at: null, delivery_time: "До обеда", created_from_exchange_at: null, @@ -36,14 +57,93 @@ describe("mapOrderGroupRowToDeliveryGroup", () => { expect(group.customer.name).toBe("Калинина Дарья Егоровна"); expect(group.customer.phone).toBe("3939375462"); expect(group.customer.date).toBe("14.04.26"); + expect(group.deliveryAddress).toBe("Симферополь, ул. Ленина, 10"); expect(group.ordersCount).toBe(1); expect(group.readyCount).toBe(1); expect(group.notReadyCount).toBe(0); expect(group.orderNumbers).toEqual(["СФ Т\\ЕА-23094"]); expect(group.displayTitle).toBe("Калинина Дарья Егоровна"); expect(group.displaySubtitle).toBe("3939375462 · 14.04.26"); - expect(group.deliveryDate).toBe("14.04.26"); + expect(group.deliveryDate).toBe("2026-04-16"); expect(group.deliveryHalfDay).toBe("Первая половина дня"); expect(group.deliveryStatus).toBe("agreed"); + expect(group.searchText).toContain("симферополь, ул. ленина, 10"); + }); + + it("infers readiness counters from order numbers for ready groups when counters are empty", () => { + const group = mapOrderGroupRowToDeliveryGroup({ + id: "group-without-counters", + group_key: "9781632663|28.04.26", + order_numbers: ["СФ Т\\ЕА-26979"], + status: "ready_for_notification", + delivery_status: "pending_confirmation", + created_at: "2026-05-05 09:43:53.750061+00", + updated_at: "2026-05-05 09:43:53.750061+00", + }); + + expect(group.ordersCount).toBe(1); + expect(group.readyCount).toBe(1); + expect(group.notReadyCount).toBe(0); + expect(group.deliveryDate).toBe(""); + }); +}); + +describe("updateOrderGroupDeliveryChoice", () => { + beforeEach(() => { + fromMock.mockReset(); + updateMock.mockReset(); + eqMock.mockReset(); + selectMock.mockReset(); + singleMock.mockReset(); + + fromMock.mockReturnValue({ update: updateMock }); + updateMock.mockReturnValue({ eq: eqMock }); + eqMock.mockReturnValue({ select: selectMock }); + selectMock.mockReturnValue({ single: singleMock }); + }); + + it("updates the group directly in order_groups", async () => { + singleMock.mockResolvedValueOnce({ + data: { + id: "group-id", + group_key: "9788151605|18.02.26", + status: "ready_for_notification", + delivery_status: "agreed", + delivery_date: "2026-05-13", + delivery_time: "Первая половина дня", + notification_status: "confirmed", + created_at: "2026-05-12T09:00:00Z", + updated_at: "2026-05-12T09:05:00Z", + }, + error: null, + }); + + await expect( + updateOrderGroupDeliveryChoice({ + orderGroupId: "group-id", + deliveryDate: "2026-05-13", + deliveryTime: "Первая половина дня", + }), + ).resolves.toMatchObject({ + data: expect.objectContaining({ + id: "group-id", + deliveryStatus: "agreed", + deliveryDate: "2026-05-13", + deliveryTime: "Первая половина дня", + }), + error: null, + }); + + expect(fromMock).toHaveBeenCalledWith("order_groups"); + expect(updateMock).toHaveBeenCalledWith({ + delivery_status: "agreed", + delivery_date: "2026-05-13", + delivery_time: "Первая половина дня", + notification_status: "confirmed", + updated_at: expect.any(String), + }); + expect(eqMock).toHaveBeenCalledWith("id", "group-id"); + expect(selectMock).toHaveBeenCalledWith("*"); + expect(singleMock).toHaveBeenCalledTimes(1); }); }); diff --git a/supabase/functions/README.md b/supabase/functions/README.md index 171d821..ee5ff9c 100644 --- a/supabase/functions/README.md +++ b/supabase/functions/README.md @@ -55,6 +55,10 @@ curl -X POST \ Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет `delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL. +Обязательная переменная окружения: + +- `PUBLIC_APP_URL` + ## `get-delivery-invitation` Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа @@ -65,6 +69,11 @@ curl -X POST \ Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает историю события. +## `update-order-group-delivery-choice` + +Фиксирует ручное согласование доставки по группе `order_groups`. +Используется менеджером или логистом, когда клиент согласовал дату и половину дня напрямую. + ## `transfer-to-logistics` Используется для ручной передачи заказа логисту или перевода в `Платное хранение`. diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts index 930c00b..9113106 100644 --- a/supabase/functions/_shared/delivery-invitations.ts +++ b/supabase/functions/_shared/delivery-invitations.ts @@ -84,6 +84,25 @@ export const getClientInvitationStateFromOrderStatus = ( } }; +export const getClientInvitationStateFromOrderGroupStatus = ( + deliveryStatus: string | null | undefined, + invitationState: string | null | undefined, +): DeliveryInvitationPublicState => { + if (deliveryStatus === "agreed") { + return "agreed"; + } + + if (deliveryStatus === "delivered") { + return "delivered"; + } + + if (["awaiting_choice", "opened", "reminder_sent"].includes(String(invitationState || ""))) { + return invitationState as DeliveryInvitationPublicState; + } + + return "default"; +}; + export const isActiveInvitationState = (state: DeliveryInvitationPublicState) => state === "awaiting_choice" || state === "opened" || state === "reminder_sent"; @@ -100,6 +119,25 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => { return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS]; }; +export const buildDefaultDatedAvailableSlots = (now = new Date()) => { + const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10); + const addDays = (date: Date, days: number) => { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + days); + return next; + }; + + const firstDay = formatIsoDate(addDays(now, 1)); + const secondDay = formatIsoDate(addDays(now, 2)); + + return [ + `${firstDay}, Первая половина дня`, + `${firstDay}, Вторая половина дня`, + `${secondDay}, Первая половина дня`, + `${secondDay}, Вторая половина дня`, + ]; +}; + export const resolvePublicAppUrl = ( request: Request, fallbackEnv?: string, @@ -116,7 +154,8 @@ export const buildInvitationUrl = (baseUrl: string, token: string) => export type DeliveryInvitationRecord = { id?: string; - order_id: string; + order_id?: string | null; + order_group_id?: string | null; token_hash: string; state: string; order_number?: string | null; @@ -137,6 +176,22 @@ export type DeliveryInvitationRecord = { updated_at?: string | null; }; +export type OrderGroupInvitationSource = { + id: string; + group_key?: string | null; + customer?: { + name?: string | null; + phone?: string | null; + date?: string | null; + } | null; + customer_name?: string | null; + customer_phone?: string | null; + customer_date?: string | null; + order_numbers?: string[] | null; + delivery_status?: string | null; + delivery_link?: string | null; +}; + export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => { if (invitation.revoked_at) { return true; @@ -149,6 +204,40 @@ export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = return new Date(invitation.expires_at).getTime() <= now.getTime(); }; +const parseGroupKey = (groupKey?: string | null) => { + const [phone = "", date = ""] = String(groupKey || "").split("|"); + return { + phone: phone.trim(), + date: date.trim(), + }; +}; + +export const buildPublicOrderGroupInvitationView = ( + invitation: DeliveryInvitationRecord, + group: OrderGroupInvitationSource, +) => { + const parsedKey = parseGroupKey(group.group_key); + const customerName = group.customer_name || group.customer?.name || invitation.customer_name || null; + const customerPhone = group.customer_phone || group.customer?.phone || invitation.customer_phone || parsedKey.phone || null; + const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : []; + + return { + orderId: invitation.order_group_id || group.id, + orderGroupId: invitation.order_group_id || group.id, + state: invitation.state, + token: "", + orderNumber: invitation.order_number || group.group_key || orderNumbers[0] || null, + customerName: maskCustomerName(customerName), + customerPhone: maskPhoneNumber(customerPhone), + orderItems: orderNumbers.map((number) => ({ name: number, quantity: "" })), + availableSlots: invitation.available_slots || [], + deliveryDate: invitation.delivery_date || null, + deliveryTime: invitation.delivery_time || null, + orderStatus: null, + deliveryAgreementStatus: null, + }; +}; + export const buildPublicInvitationView = ( invitation: DeliveryInvitationRecord, order: { diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index 54c3ac7..24254d3 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -104,6 +104,95 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); } + if (invitation.order_group_id) { + const { data: currentGroup, error: groupError } = await supabase + .from("order_groups") + .select("id, delivery_status") + .eq("id", invitation.order_group_id) + .single(); + + if (groupError) { + throw groupError; + } + + if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") { + return jsonResponse( + { + ok: false, + error: "Invitation is no longer active", + }, + 409, + corsHeaders, + ); + } + + const requestedSlot = resolveRequestedSlot(invitation, body); + if (!requestedSlot) { + return jsonResponse( + { + ok: false, + error: "Selected slot is not available", + }, + 422, + corsHeaders, + ); + } + + const { error: invitationUpdateError } = await supabase + .from("delivery_invitations") + .update({ + state: "agreed", + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + confirmed_at: new Date().toISOString(), + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + + if (invitationUpdateError) { + throw invitationUpdateError; + } + + const { error: groupUpdateError } = await supabase + .from("order_groups") + .update({ + delivery_status: "agreed", + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + notification_status: "confirmed", + updated_at: new Date().toISOString(), + }) + .eq("id", invitation.order_group_id); + + if (groupUpdateError) { + throw groupUpdateError; + } + + await insertIntegrationEvent(supabase, { + order_id: null, + event_type: "delivery_choice_confirmed", + direction: "inbound", + status: "success", + payload: { + order_group_id: invitation.order_group_id, + delivery_invitation_id: invitation.id, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + }, + }); + + return jsonResponse( + { + ok: true, + orderGroupId: invitation.order_group_id, + deliveryStatus: "agreed", + }, + 200, + corsHeaders, + ); + } + const { data: currentOrder, error: orderError } = await supabase .from("orders") .select("id, status, delivery_agreement_status") diff --git a/supabase/functions/create-delivery-invitation/index.ts b/supabase/functions/create-delivery-invitation/index.ts index 23ed618..e168aaf 100644 --- a/supabase/functions/create-delivery-invitation/index.ts +++ b/supabase/functions/create-delivery-invitation/index.ts @@ -1,4 +1,5 @@ import { + buildDefaultDatedAvailableSlots, buildInvitationUrl, generateInvitationToken, getOrderUpdateForDeliveryInvitationAction, @@ -21,6 +22,7 @@ const MAX_BODY_BYTES = 16 * 1024; type CreateInvitationBody = { orderId?: string; + orderGroupId?: string; orderNumber?: string; customerName?: string; customerPhone?: string; @@ -29,6 +31,197 @@ type CreateInvitationBody = { source?: string; }; +const parseGroupKey = (groupKey?: string | null) => { + const [phone = "", date = ""] = String(groupKey || "").split("|"); + return { + phone: phone.trim(), + date: date.trim(), + }; +}; + +const resolveRequiredPublicAppUrl = (request: Request) => { + const publicBaseUrl = resolvePublicAppUrl(request); + if (!publicBaseUrl) { + throw new Error("PUBLIC_APP_URL is not configured"); + } + + return publicBaseUrl; +}; + +const createOrderGroupInvitation = async ({ + body, + request, + corsHeaders, +}: { + body: CreateInvitationBody; + request: Request; + corsHeaders: HeadersInit; +}) => { + const supabase = createServiceClient(); + const orderGroupId = String(body.orderGroupId || "").trim(); + + await requireRateLimit(supabase, { + scope: "delivery-invitation-create", + key: orderGroupId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { data: group, error: groupError } = await supabase + .from("order_groups") + .select("*") + .eq("id", orderGroupId) + .single(); + + if (groupError) { + throw groupError; + } + + const parsedKey = parseGroupKey(group.group_key); + const customerName = body.customerName || group.customer_name || group.customer?.name || null; + const customerPhone = body.customerPhone || group.customer_phone || group.customer?.phone || parsedKey.phone || null; + const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : []; + const orderNumber = body.orderNumber || group.group_key || orderNumbers[0] || null; + + if (!customerPhone) { + return jsonResponse({ ok: false, error: "customerPhone is required" }, 400, corsHeaders); + } + + const { data: existingInvitation, error: existingInvitationError } = await supabase + .from("delivery_invitations") + .select("id, state") + .eq("order_group_id", orderGroupId) + .in("state", ["awaiting_choice", "opened", "reminder_sent"]) + .maybeSingle(); + + if (existingInvitationError) { + throw existingInvitationError; + } + + if (existingInvitation) { + if (!group.delivery_link) { + const { error: revokeInvitationError } = await supabase + .from("delivery_invitations") + .update({ + state: "default", + revoked_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("id", existingInvitation.id); + + if (revokeInvitationError) { + throw revokeInvitationError; + } + } else { + return jsonResponse( + { + ok: true, + alreadyStarted: true, + invitation: { + id: existingInvitation.id, + orderGroupId, + state: existingInvitation.state, + url: group.delivery_link || null, + }, + }, + 200, + corsHeaders, + ); + } + } + + if (existingInvitation && !group.delivery_link) { + const { error: clearBrokenLinkError } = await supabase + .from("order_groups") + .update({ + delivery_invitation_id: null, + updated_at: new Date().toISOString(), + }) + .eq("id", orderGroupId); + + if (clearBrokenLinkError) { + throw clearBrokenLinkError; + } + } + + const token = generateInvitationToken(); + const tokenHash = await hashInvitationToken(token); + const publicBaseUrl = resolveRequiredPublicAppUrl(request); + const url = buildInvitationUrl(publicBaseUrl, token); + const availableSlots = body.availableSlots?.length + ? normalizeAvailableSlots(body.availableSlots) + : buildDefaultDatedAvailableSlots(); + + const invitationPayload = { + order_id: null, + order_group_id: orderGroupId, + token_hash: tokenHash, + state: "awaiting_choice", + order_number: orderNumber, + customer_name: customerName, + customer_phone: customerPhone, + customer_messenger: body.customerMessenger || null, + available_slots: availableSlots, + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + sent_at: null, + }; + + const { data: invitation, error: invitationError } = await supabase + .from("delivery_invitations") + .insert(invitationPayload) + .select("id") + .single(); + + if (invitationError) { + throw invitationError; + } + + const { error: groupUpdateError } = await supabase + .from("order_groups") + .update({ + delivery_invitation_id: invitation.id, + delivery_link: url, + notification_status: "link_ready", + next_notification_check_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq("id", orderGroupId); + + if (groupUpdateError) { + throw groupUpdateError; + } + + await insertIntegrationEvent(supabase, { + order_id: null, + event_type: "delivery_invitation_created", + direction: "outbound", + status: "success", + payload: { + order_group_id: orderGroupId, + delivery_invitation_id: invitation.id, + token_hash: tokenHash, + available_slots: availableSlots, + }, + }); + + return jsonResponse( + { + ok: true, + invitation: { + id: invitation.id, + orderGroupId, + token, + url, + state: "awaiting_choice", + availableSlots, + }, + }, + 200, + corsHeaders, + ); +}; + Deno.serve(async (request) => { if (request.method === "OPTIONS") { const corsHeaders = getCorsHeaders(request, "integration"); @@ -50,14 +243,19 @@ Deno.serve(async (request) => { allowedClockSkewSeconds: 300, }); - if (!body.orderId) { - return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); + if (!body.orderId && !body.orderGroupId) { + return jsonResponse({ error: "orderId or orderGroupId is required" }, 400, corsHeaders); } + if (body.orderGroupId) { + return await createOrderGroupInvitation({ body, request, corsHeaders }); + } + + const orderId = body.orderId as string; const supabase = createServiceClient(); await requireRateLimit(supabase, { scope: "delivery-invitation-create", - key: body.orderId, + key: orderId, maxCount: 10, windowSeconds: 600, blockSeconds: 1800, @@ -70,7 +268,7 @@ Deno.serve(async (request) => { const { data: currentOrder, error: orderError } = await supabase .from("orders") .select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at") - .eq("id", body.orderId) + .eq("id", orderId) .single(); if (orderError) { @@ -82,7 +280,7 @@ Deno.serve(async (request) => { .select( "id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at", ) - .eq("order_id", body.orderId) + .eq("order_id", orderId) .maybeSingle(); if (existingInvitationError) { @@ -96,7 +294,7 @@ Deno.serve(async (request) => { alreadyStarted: true, invitation: existingInvitation ? { - orderId: body.orderId, + orderId, state: existingInvitation.state, availableSlots: existingInvitation.available_slots || [], orderNumber: existingInvitation.order_number || body.orderNumber || null, @@ -105,7 +303,7 @@ Deno.serve(async (request) => { customerMessenger: existingInvitation.customer_messenger || body.customerMessenger || null, } : { - orderId: body.orderId, + orderId, state: "awaiting_choice", }, }, @@ -115,7 +313,7 @@ Deno.serve(async (request) => { } const invitationPayload = { - order_id: body.orderId, + order_id: orderId, token_hash: tokenHash, state: "awaiting_choice", order_number: body.orderNumber || null, @@ -142,14 +340,14 @@ Deno.serve(async (request) => { delivery_flow_started_at: new Date().toISOString(), delivery_flow_source: body.source || "n8n", }) - .eq("id", body.orderId); + .eq("id", orderId); if (updateError) { throw updateError; } const { error: historyError } = await supabase.from("order_history").insert({ - order_id: body.orderId, + order_id: orderId, action: "Создание приглашения доставки", old_status: currentOrder.status, new_status: orderUpdate?.status, @@ -166,7 +364,7 @@ Deno.serve(async (request) => { } await insertIntegrationEvent(supabase, { - order_id: body.orderId, + order_id: orderId, event_type: "delivery_invitation_created", direction: "outbound", status: "success", @@ -176,13 +374,13 @@ Deno.serve(async (request) => { }, }); - const publicBaseUrl = resolvePublicAppUrl(request); + const publicBaseUrl = resolveRequiredPublicAppUrl(request); return jsonResponse( { ok: true, invitation: { - orderId: body.orderId, + orderId, token, url: buildInvitationUrl(publicBaseUrl, token), state: "awaiting_choice", diff --git a/supabase/functions/get-delivery-invitation/index.ts b/supabase/functions/get-delivery-invitation/index.ts index effefd9..3144e55 100644 --- a/supabase/functions/get-delivery-invitation/index.ts +++ b/supabase/functions/get-delivery-invitation/index.ts @@ -1,5 +1,7 @@ import { + buildPublicOrderGroupInvitationView, buildPublicInvitationView, + getClientInvitationStateFromOrderGroupStatus, getClientInvitationStateFromOrderStatus, hashInvitationToken, isActiveInvitationState, @@ -82,6 +84,49 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); } + if (invitation.order_group_id) { + const { data: group, error: groupError } = await supabase + .from("order_groups") + .select("*") + .eq("id", invitation.order_group_id) + .single(); + + if (groupError) { + throw groupError; + } + + const publicState = getClientInvitationStateFromOrderGroupStatus( + group.delivery_status, + invitation.state, + ); + + await supabase + .from("delivery_invitations") + .update({ + opened_at: isActiveInvitationState(publicState) && !invitation.opened_at + ? new Date().toISOString() + : invitation.opened_at, + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + + const invitationView = buildPublicOrderGroupInvitationView(invitation, group); + + return jsonResponse( + { + ok: true, + invitation: { + ...invitationView, + token, + state: publicState, + }, + }, + 200, + corsHeaders, + ); + } + const { data: order, error: orderError } = await supabase .from("orders") .select("id, order_number, status, delivery_agreement_status, customer") diff --git a/supabase/functions/update-order-group-delivery-choice/index.ts b/supabase/functions/update-order-group-delivery-choice/index.ts new file mode 100644 index 0000000..0817f8e --- /dev/null +++ b/supabase/functions/update-order-group-delivery-choice/index.ts @@ -0,0 +1,230 @@ +import { createServiceClient } from "../_shared/chatbot.ts"; +import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; +const ALLOWED_ROLES = new Set(["manager", "logistician", "admin"]); +const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]); +const DELIVERY_TIME_ALIASES = new Map([ + ["До обеда", "Первая половина дня"], + ["После обеда", "Вторая половина дня"], +]); +const DELIVERY_TIMEZONE = "Europe/Simferopol"; + +type UpdateDeliveryChoiceBody = { + orderGroupId?: string; + deliveryDate?: string; + deliveryTime?: string; +}; + +const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + +const getTodayKey = () => { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: DELIVERY_TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + + const year = parts.find((part) => part.type === "year")?.value || ""; + const month = parts.find((part) => part.type === "month")?.value || ""; + const day = parts.find((part) => part.type === "day")?.value || ""; + + return `${year}-${month}-${day}`; +}; + +const isWeekendDeliveryDate = (value: string) => { + if (!isValidDate(value)) { + return false; + } + + const date = new Date(`${value}T12:00:00Z`); + const weekday = date.getUTCDay(); + return weekday === 0 || weekday === 6; +}; + +const isAllowedDeliveryDate = (value: string) => isValidDate(value) && value > getTodayKey() && !isWeekendDeliveryDate(value); + +const normalizeDeliveryTime = (value: string) => DELIVERY_TIME_ALIASES.get(value) || value; + +const getBearerToken = (request: Request) => { + const authorization = request.headers.get("authorization") || ""; + return authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice(7).trim() + : ""; +}; + +const getUserRole = async ( + supabase: ReturnType, + accessToken: string, +) => { + const { data: authData, error: authError } = await supabase.auth.getUser(accessToken); + if (authError || !authData.user?.id) { + return null; + } + + const { data: profile, error: profileError } = await supabase + .from("users") + .select("id, role_info:roles(name)") + .eq("id", authData.user.id) + .single(); + + if (profileError) { + throw profileError; + } + + const roleInfo = Array.isArray(profile.role_info) ? profile.role_info[0] : profile.role_info; + return { + userId: authData.user.id, + role: roleInfo?.name || "", + }; +}; + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + + const orderGroupId = String(body.orderGroupId || "").trim(); + const deliveryDate = String(body.deliveryDate || "").trim(); + const deliveryTime = normalizeDeliveryTime(String(body.deliveryTime || "").trim()); + + if (!orderGroupId) { + return jsonResponse({ ok: false, error: "orderGroupId is required" }, 400, corsHeaders); + } + + if (!isAllowedDeliveryDate(deliveryDate)) { + return jsonResponse({ ok: false, error: "Выберите будущий будний день доставки" }, 400, corsHeaders); + } + + if (!ALLOWED_DELIVERY_TIMES.has(deliveryTime)) { + return jsonResponse({ ok: false, error: "Выберите первую или вторую половину дня доставки" }, 400, corsHeaders); + } + + const accessToken = getBearerToken(request); + if (!accessToken) { + return jsonResponse({ ok: false, error: "Authentication is required" }, 401, corsHeaders); + } + + const supabase = createServiceClient(); + const actor = await getUserRole(supabase, accessToken); + + if (!actor || !ALLOWED_ROLES.has(actor.role)) { + return jsonResponse({ ok: false, error: "Forbidden" }, 403, corsHeaders); + } + + const ipHash = await hashText(getClientIp(request)); + await requireRateLimit(supabase, { + scope: "order-group-manual-delivery-choice", + key: `${actor.userId}:${ipHash}:${orderGroupId}`, + maxCount: 20, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { data: currentGroup, error: currentGroupError } = await supabase + .from("order_groups") + .select("id, delivery_status, delivery_invitation_id") + .eq("id", orderGroupId) + .single(); + + if (currentGroupError) { + throw currentGroupError; + } + + const { data: group, error: groupUpdateError } = await supabase + .from("order_groups") + .update({ + delivery_status: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + notification_status: "confirmed", + updated_at: new Date().toISOString(), + }) + .eq("id", orderGroupId) + .select("*") + .single(); + + if (groupUpdateError) { + throw groupUpdateError; + } + + if (currentGroup.delivery_invitation_id) { + const { error: invitationUpdateError } = await supabase + .from("delivery_invitations") + .update({ + state: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + confirmed_at: new Date().toISOString(), + }) + .eq("id", currentGroup.delivery_invitation_id); + + if (invitationUpdateError) { + throw invitationUpdateError; + } + } + + await insertIntegrationEvent(supabase, { + order_id: null, + event_type: "order_group_manual_delivery_choice", + direction: "internal", + status: "success", + payload: { + order_group_id: orderGroupId, + actor_user_id: actor.userId, + actor_role: actor.role, + old_delivery_status: currentGroup.delivery_status || null, + new_delivery_status: "agreed", + delivery_date: deliveryDate, + delivery_time: deliveryTime, + }, + }); + + return jsonResponse( + { + ok: true, + orderGroup: group, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/supabase/schema.sql b/supabase/schema.sql index 6fea8af..b02c72e 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -85,9 +85,42 @@ create table if not exists public.error_logs ( created_at timestamptz not null default timezone('utc', now()) ); +create table if not exists public.order_groups ( + id uuid primary key default gen_random_uuid(), + group_key text not null, + customer jsonb, + order_numbers text[] not null default '{}', + status text not null default 'ready_for_notification', + delivery_status text not null default 'pending_confirmation', + sms_sent_at timestamptz, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + created_from_exchange_at timestamptz, + source_key text, + customer_name text, + customer_phone text, + customer_phone_normalized text, + customer_date text, + orders_total integer, + orders_ready integer, + orders_not_ready integer, + source_orders jsonb, + delivery_invitation_id uuid, + delivery_link text, + notification_status text not null default 'not_started', + sms_attempts integer not null default 0, + first_sms_sent_at timestamptz, + second_sms_sent_at timestamptz, + last_sms_error text, + next_notification_check_at timestamptz, + delivery_date date, + delivery_time text +); + create table if not exists public.delivery_invitations ( id uuid primary key default gen_random_uuid(), - order_id uuid not null references public.orders (id) on delete cascade unique, + order_id uuid references public.orders (id) on delete cascade unique, + order_group_id uuid references public.order_groups (id) on delete cascade, token_hash text not null unique, state text not null default 'awaiting_choice', order_number text, @@ -146,6 +179,8 @@ alter table public.chat_messages check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email')); alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice'; +alter table public.delivery_invitations alter column order_id drop not null; +alter table public.delivery_invitations add column if not exists order_group_id uuid references public.order_groups(id) on delete cascade; alter table public.delivery_invitations add column if not exists expires_at timestamptz; alter table public.delivery_invitations add column if not exists revoked_at timestamptz; alter table public.delivery_invitations add column if not exists access_count integer not null default 0; @@ -160,6 +195,17 @@ alter table public.delivery_invitations add column if not exists paid_storage_at 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.order_groups add column if not exists delivery_invitation_id uuid references public.delivery_invitations(id) on delete set null; +alter table public.order_groups add column if not exists delivery_link text; +alter table public.order_groups add column if not exists notification_status text not null default 'not_started'; +alter table public.order_groups add column if not exists sms_attempts integer not null default 0; +alter table public.order_groups add column if not exists first_sms_sent_at timestamptz; +alter table public.order_groups add column if not exists second_sms_sent_at timestamptz; +alter table public.order_groups add column if not exists last_sms_error text; +alter table public.order_groups add column if not exists next_notification_check_at timestamptz; +alter table public.order_groups add column if not exists delivery_date date; +alter table public.order_groups add column if not exists delivery_time text; + 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; @@ -373,9 +419,13 @@ create index if not exists idx_chat_messages_search on public.chat_messages usin to_tsvector('russian', coalesce(text, '')) ); create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id); +create index if not exists idx_delivery_invitations_order_group_id on public.delivery_invitations (order_group_id); create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash); create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state); create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at); +create index if not exists idx_order_groups_status on public.order_groups (status); +create index if not exists idx_order_groups_delivery_status on public.order_groups (delivery_status); +create index if not exists idx_order_groups_notification_status on public.order_groups (notification_status, next_notification_check_at); create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc); create index if not exists idx_integration_events_event_type on public.integration_events (event_type); create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc); @@ -465,6 +515,7 @@ alter table public.order_history enable row level security; alter table public.delivery_slots enable row level security; alter table public.chat_messages enable row level security; alter table public.error_logs enable row level security; +alter table public.order_groups enable row level security; alter table public.delivery_invitations enable row level security; alter table public.integration_events enable row level security; @@ -597,6 +648,22 @@ create policy "history insert workflow" on public.order_history for insert with check (public.current_role_name() in ('manager', 'production_lead', 'logistician', 'driver', 'admin')); +drop policy if exists "order groups select by role" on public.order_groups; +create policy "order groups select by role" on public.order_groups +for select +using (true); + +drop policy if exists "order groups update coordination roles" on public.order_groups; +create policy "order groups update coordination roles" on public.order_groups +for update +using (public.current_role_name() in ('manager', 'logistician', 'admin')) +with check (public.current_role_name() in ('manager', 'logistician', 'admin')); + +drop policy if exists "order groups insert service roles" on public.order_groups; +create policy "order groups insert service roles" on public.order_groups +for insert +with check (public.current_role_name() in ('manager', 'logistician', 'admin')); + drop policy if exists "slots by order role" on public.delivery_slots; create policy "slots by order role" on public.delivery_slots for all