feat(delivery): auto-create invitation links
Add a Supabase trigger that creates delivery invitations and writes delivery_link on order_groups changes. n8n can now consume link_ready rows without calling the edge function first.
This commit is contained in:
parent
684424dd25
commit
b79de7afba
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = <provider 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 = <provider 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.
|
||||
|
|
@ -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: <SUPABASE_ANON_KEY>
|
||||
-- Authorization: Bearer <SUPABASE_ANON_KEY>
|
||||
-- x-n8n-secret: <LONG_RANDOM_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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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: <SUPABASE_SERVICE_ROLE_KEY>
|
||||
-- Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
|
||||
-- Content-Type: application/json
|
||||
-- Prefer: resolution=merge-duplicates,return=representation
|
||||
--
|
||||
-- Endpoint example:
|
||||
-- POST https://<project-ref>.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;
|
||||
|
|
@ -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 `<Input type="date">`**
|
||||
|
||||
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
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
<p className="mt-1 font-medium">{order.smsSentAt ? "Да" : "Нет"}</p>
|
||||
```
|
||||
|
||||
- [ ] **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.
|
||||
|
|
@ -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 `<select>` in the delivery filters with a themed dropdown that matches the app styling and does not expose browser-default chrome.
|
||||
|
||||
**Architecture:** Keep the change local to the existing filters component so behavior stays the same while the rendering shifts from a native form control to an app-styled button plus floating menu. Reuse the existing filter state and active-chip logic so downstream list filtering does not change.
|
||||
|
||||
**Tech Stack:** React 18, Vitest, Tailwind utility classes with CSS variables.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/components/orders/OrderFilters.jsx`
|
||||
Responsible for rendering the search field and the themed status dropdown, including open/close behavior and option selection.
|
||||
|
||||
- Modify: `src/components/orders/OrderFilters.test.jsx`
|
||||
Server-rendered tests for the new dropdown markup and the existing active-chip behavior.
|
||||
|
||||
## Chunk 1: Themed Dropdown Rendering
|
||||
|
||||
### Task 1: Replace the Native `<select>` With a Custom Dropdown
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/orders/OrderFilters.jsx`
|
||||
|
||||
- [ ] **Step 1: Replace the status `<select>` with a button-triggered dropdown**
|
||||
|
||||
Use the existing `statusOptions` array and `filters.displayStatus` state, but render a button that opens a floating menu of options instead of the browser's native select chrome.
|
||||
|
||||
- [ ] **Step 2: Keep the current filter semantics unchanged**
|
||||
|
||||
Selecting an option must still call `setFilters((current) => ({ ...current, displayStatus: value }))`.
|
||||
|
||||
- [ ] **Step 3: Keep the component visually aligned with the app theme**
|
||||
|
||||
Use the same rounded borders, surface/background colors, accent border on focus/open, and muted text used elsewhere in the dashboard.
|
||||
|
||||
### Task 2: Update the Component Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/orders/OrderFilters.test.jsx`
|
||||
|
||||
- [ ] **Step 1: Update the expectation that currently checks for `<select>`**
|
||||
|
||||
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.
|
||||
|
|
@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-semibold tracking-[0.01em] shadow-sm",
|
||||
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-center text-xs font-semibold leading-tight tracking-[0.01em] shadow-sm",
|
||||
{
|
||||
"border-[rgba(18,128,92,0.18)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent",
|
||||
"border-[rgba(201,61,61,0.22)] bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger",
|
||||
"border-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
|
||||
"border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
|||
() => groupOrderGroupsByDate(filteredOrderGroups),
|
||||
[filteredOrderGroups],
|
||||
);
|
||||
const deliveryCountLabel = `${filteredOrderGroups.length} ${
|
||||
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -57,12 +60,14 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
|||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h3 className="text-lg font-semibold">Мои доставки</h3>
|
||||
<Badge tone="neutral">{deliveryCountLabel}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня.
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="neutral">{filteredOrderGroups.length}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[repeat(4,minmax(0,1fr))]">
|
||||
|
|
|
|||
|
|
@ -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("Не показывать");
|
||||
|
|
|
|||
|
|
@ -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 <span>Номера не указаны</span>;
|
||||
|
|
@ -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 }) => {
|
|||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Группы из таблицы `order_groups`, разбитые по состоянию готовности.
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="neutral">{totalGroups} групп</Badge>
|
||||
</div>
|
||||
|
|
@ -73,7 +65,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
|
|||
<OrderFilters
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
statusOptions={ORDER_GROUP_STATUS_OPTIONS}
|
||||
statusOptions={ORDER_GROUP_DISPLAY_STATUS_OPTIONS}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
|
|
@ -119,19 +111,20 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||
<div className="break-words text-base font-semibold leading-tight !text-[var(--color-text)] sm:text-lg">
|
||||
{group.displayTitle || group.customerName || group.groupKey}
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
<Badge className="self-start" tone={getOrderGroupStatusTone(group)}>
|
||||
{getOrderGroupDisplayStatusLabel(group)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{group.customerDate || "—"} · {group.customerPhone || "—"} · {group.ordersCount || 0}{" "}
|
||||
{group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}
|
||||
</div>
|
||||
<div className="mt-2">{renderOrderNumbers(group)}</div>
|
||||
</div>
|
||||
|
||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupStatusLabel(group.status)}</Badge>
|
||||
<div>{renderOrderNumbers(group)}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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("Согласовано");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Panel className="flex min-h-[460px] items-center justify-center">
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-5">
|
||||
<Panel className="space-y-5 p-6">
|
||||
|
|
@ -48,7 +285,22 @@ export const OrderDetailPanel = ({ order }) => {
|
|||
{order.displaySubtitle || [order.customerPhone, order.customerDate].filter(Boolean).join(" · ") || "Не указано"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupStatusLabel(order.status)}</Badge>
|
||||
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Дата доставки
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold">{formatDeliveryDateDisplay(order.deliveryDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Время доставки
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
|
|
@ -68,6 +320,10 @@ export const OrderDetailPanel = ({ order }) => {
|
|||
<p className="text-xs text-[var(--color-text-muted)]">Дата</p>
|
||||
<p className="mt-1 font-medium">{renderValue(order.customerDate)}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 xl:col-span-4">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Адрес доставки</p>
|
||||
<p className="mt-1 font-medium">{renderValue(order.deliveryAddress)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p>
|
||||
<p className="mt-1 font-medium">{order.ordersCount ?? 0}</p>
|
||||
|
|
@ -84,9 +340,182 @@ export const OrderDetailPanel = ({ order }) => {
|
|||
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
|
||||
<p className="mt-1 font-medium">{formatDateTime(order.updatedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Статус доставки</p>
|
||||
<p className="mt-1 font-medium">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{canManageDelivery ? (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div>
|
||||
<strong>Ручное согласование доставки</strong>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{isDeliveryAgreed
|
||||
? "Дата и половина дня доставки уже зафиксированы."
|
||||
: "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."}
|
||||
</p>
|
||||
</div>
|
||||
{isDeliveryAgreed ? (
|
||||
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 text-[var(--color-text)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
|
||||
Доставка согласована
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{agreedDeliveryLabel || "Дата и время сохранены"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="accent">Согласовано</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10">
|
||||
<div className="space-y-3 md:relative md:z-30 md:min-w-0 md:flex-1 md:pr-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Дата доставки"
|
||||
aria-expanded={isCalendarOpen}
|
||||
className="flex min-h-[54px] w-full items-center justify-between rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-left text-sm font-medium text-[var(--color-text)] transition hover:border-[var(--color-accent)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
onClick={() => setIsCalendarOpen((current) => !current)}
|
||||
>
|
||||
<span>{formatDateForDisplay(deliveryDate)}</span>
|
||||
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
||||
</button>
|
||||
{isCalendarOpen ? (
|
||||
<div className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-soft md:absolute md:left-0 md:top-full md:z-50 md:mt-3 md:w-[min(460px,calc(100vw-3rem))]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Календарь доставки
|
||||
</p>
|
||||
<h4
|
||||
className="mt-1 text-base font-semibold capitalize"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{monthLabel}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoBack}
|
||||
aria-label="Предыдущий месяц"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => setCurrentMonth((month) => addMonths(month, -1))}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующий месяц"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setCurrentMonth((month) => addMonths(month, 1))}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-[10px] font-semibold uppercase text-[var(--color-text-muted)]">
|
||||
{WEEK_DAY_LABELS.map((day) => (
|
||||
<div key={day} className="px-1 py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-7 gap-1">
|
||||
{calendarDays.map((day, index) => {
|
||||
if (!day) {
|
||||
return <div key={`empty-${index}`} className="aspect-square" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
key={dateKey}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
title={isWeekend ? "Выходной, доставки нет" : isSelectable ? "Можно выбрать" : "Недоступно"}
|
||||
className={[
|
||||
"relative flex aspect-square items-center justify-center rounded-xl border text-sm font-semibold transition",
|
||||
isSelected
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: isWeekend
|
||||
? "border-dashed border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||
isDisabled ? "cursor-not-allowed opacity-45" : "",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeliveryDate(dateKey);
|
||||
setFormMessage("");
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{dayNumber}</span>
|
||||
{isWeekend ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-2 top-1/2 h-px -rotate-12 bg-[var(--color-text-muted)] opacity-70"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
Выходные отмечены пунктиром и недоступны.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none">
|
||||
{DELIVERY_TIME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={deliveryTime === option}
|
||||
className={[
|
||||
"min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition",
|
||||
deliveryTime === option
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setDeliveryTime(option);
|
||||
setFormMessage("");
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full md:w-[180px] md:flex-none md:self-start"
|
||||
onClick={handleSaveDeliveryChoice}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
>
|
||||
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{formMessage ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
|
||||
) : null}
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
<Panel className="space-y-4 p-5">
|
||||
<strong>Номера заказов</strong>
|
||||
{renderList(order.orderNumbers)}
|
||||
|
|
@ -97,31 +526,22 @@ export const OrderDetailPanel = ({ order }) => {
|
|||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
|
||||
<p className="mt-1 font-medium">{renderValue(formatDateTime(order.smsSentAt))}</p>
|
||||
<p className="mt-1 font-medium">{order.smsSentAt ? "Да" : "Нет"}</p>
|
||||
</div>
|
||||
{order.createdFromExchangeAt ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p>
|
||||
<p className="mt-1 font-medium">{renderValue(formatDateTime(order.createdFromExchangeAt))}</p>
|
||||
<p className="mt-1 font-medium">{formatDateTime(order.createdFromExchangeAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Source key</p>
|
||||
<p className="mt-1 font-medium">{renderValue(order.sourceKey)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Legacy customer</p>
|
||||
<p className="mt-1 font-medium">{renderValue(order.legacyCustomerName)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{order.sourceOrders ? (
|
||||
<Panel className="space-y-3 p-5">
|
||||
<strong>Source orders</strong>
|
||||
<pre className="overflow-x-auto rounded-[20px] bg-[var(--color-surface-strong)] p-4 text-xs leading-6 text-[var(--color-text-muted)]">
|
||||
{JSON.stringify(order.sourceOrders, null, 2)}
|
||||
</pre>
|
||||
</Panel>
|
||||
) : null}
|
||||
{order.sourceKey ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Ключ источника</p>
|
||||
<p className="mt-1 font-medium">{order.sourceKey}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<OrderDetailPanel order={order} canManageDelivery onSaveManualDeliveryChoice={() => {}} />,
|
||||
);
|
||||
const readonlyMarkup = renderToStaticMarkup(<OrderDetailPanel order={order} />);
|
||||
|
||||
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(
|
||||
<OrderDetailPanel
|
||||
order={{
|
||||
...order,
|
||||
deliveryStatus: "agreed",
|
||||
deliveryDate: "2026-05-18",
|
||||
deliveryTime: "Первая половина дня",
|
||||
}}
|
||||
canManageDelivery
|
||||
onSaveManualDeliveryChoice={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Доставка согласована");
|
||||
expect(markup).toContain("18.05.2026 · Первая половина дня");
|
||||
expect(markup).not.toContain("Согласовать</button>");
|
||||
});
|
||||
|
||||
it("skips weekends when selecting the default manual delivery date", () => {
|
||||
expect(getNextSelectableDateKey(new Date("2026-05-15T12:00:00Z"))).toBe("2026-05-18");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Panel className="p-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.6fr)_minmax(12rem,0.7fr)]">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.6fr)_minmax(12rem,0.7fr)] md:items-end">
|
||||
<Input
|
||||
className="h-[46px] py-0"
|
||||
placeholder="Поиск по группе, клиенту или телефону"
|
||||
value={filters.query}
|
||||
onChange={(event) => updateFilter("query", event.target.value)}
|
||||
/>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-2">
|
||||
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Статус
|
||||
</span>
|
||||
<select
|
||||
className="h-[46px] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
|
||||
value={filters.status}
|
||||
onChange={(event) => updateFilter("status", event.target.value)}
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isStatusOpen}
|
||||
className={[
|
||||
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||
isStatusOpen
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||
].join(" ")}
|
||||
onClick={() => setIsStatusOpen((current) => !current)}
|
||||
>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
|
||||
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isStatusOpen ? (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft"
|
||||
>
|
||||
{statusOptions.map((option) => {
|
||||
const isSelected = option.value === statusValue;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={[
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition",
|
||||
isSelected
|
||||
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
updateFilter("displayStatus", option.value);
|
||||
setIsStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeChips.length ? (
|
||||
|
|
|
|||
|
|
@ -9,19 +9,21 @@ describe("OrderFilters", () => {
|
|||
<OrderFilters
|
||||
filters={{
|
||||
query: "",
|
||||
status: "all",
|
||||
displayStatus: "all",
|
||||
}}
|
||||
setFilters={() => {}}
|
||||
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("<select");
|
||||
expect(markup).toContain('aria-haspopup="listbox"');
|
||||
expect(markup).not.toContain("<select");
|
||||
expect(markup).not.toContain("Канал");
|
||||
expect(markup).not.toContain("Активные фильтры");
|
||||
});
|
||||
|
|
@ -31,17 +33,18 @@ describe("OrderFilters", () => {
|
|||
<OrderFilters
|
||||
filters={{
|
||||
query: "",
|
||||
status: "ready_for_notification",
|
||||
displayStatus: "delivery:agreed",
|
||||
}}
|
||||
setFilters={() => {}}
|
||||
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("Активные фильтры");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,15 +64,19 @@ export const OrdersTable = ({
|
|||
selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||
<div className="min-w-0 truncate font-medium">
|
||||
{group.displayTitle || group.customerName || group.groupKey}
|
||||
</div>
|
||||
<Badge className="self-start" tone={getOrderGroupStatusTone(group)}>
|
||||
{getOrderGroupDisplayStatusLabel(group)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-muted)]">
|
||||
{group.displaySubtitle || [group.customerPhone, group.customerDate].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupStatusLabel(group.status)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div>
|
||||
<div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderOrderNumbers(group)}</div>
|
||||
|
|
@ -121,7 +128,7 @@ export const OrdersTable = ({
|
|||
{renderOrderNumbers(group)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupStatusLabel(group.status)}</Badge>
|
||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
||||
{group.readyCount || 0}/{group.ordersCount || 0}
|
||||
|
|
|
|||
|
|
@ -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: "Согласовано" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,7 +33,9 @@ export const groupSlotsFromInvitation = (invitation) => {
|
|||
];
|
||||
}
|
||||
|
||||
return rawSlots.map((raw, index) => {
|
||||
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() || "";
|
||||
|
|
@ -47,7 +49,31 @@ export const groupSlotsFromInvitation = (invitation) => {
|
|||
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,8 +167,7 @@ export const ClientDeliveryPage = () => {
|
|||
? `${formatDeliveryDate(effectiveSelectedSlot.date)} / ${effectiveSelectedSlot.time}`
|
||||
: "";
|
||||
|
||||
const handleSaveChoice = React.useCallback(
|
||||
async () => {
|
||||
const handleSaveChoice = async () => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -177,12 +196,9 @@ export const ClientDeliveryPage = () => {
|
|||
setActionMessage("");
|
||||
setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор");
|
||||
}
|
||||
},
|
||||
[effectiveSelectedSlot, token],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSlotSelect = React.useCallback(
|
||||
(slot) => {
|
||||
const handleSlotSelect = (slot) => {
|
||||
setSelectedSlotId(slot.id);
|
||||
setSelectedSlot(slot);
|
||||
setChoiceSaved(false);
|
||||
|
|
@ -190,13 +206,11 @@ export const ClientDeliveryPage = () => {
|
|||
`Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`,
|
||||
);
|
||||
setError("");
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
const handleRequestNewLink = React.useCallback(() => {
|
||||
const handleRequestNewLink = () => {
|
||||
setActionMessage("Если ссылка больше не работает, логист передаст новую ссылку вручную.");
|
||||
}, []);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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: "Первая половина дня",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export const DashboardPage = () => {
|
|||
statusOptions,
|
||||
isLoading,
|
||||
loadError,
|
||||
saveManualDeliveryChoice,
|
||||
isSavingDeliveryChoice,
|
||||
} = useOrderGroups();
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -153,7 +155,6 @@ export const DashboardPage = () => {
|
|||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Карточка группы доставки</h3>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Все данные из таблицы `order_groups`.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -164,7 +165,12 @@ export const DashboardPage = () => {
|
|||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
<OrderDetailPanel order={selectedOrderGroup} />
|
||||
<OrderDetailPanel
|
||||
order={selectedOrderGroup}
|
||||
canManageDelivery={["manager", "logistician", "admin"].includes(userRole)}
|
||||
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
|
||||
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppShell>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
Используется для ручной передачи заказа логисту или перевода в `Платное хранение`.
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<typeof createServiceClient>,
|
||||
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<UpdateDeliveryChoiceBody>(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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue