Compare commits

..

No commits in common. "3c22eb71ab72b787bd4fdd644cfa38ff150fc7d1" and "488e478841c651b0dc65b89d4f5bc672d240c48d" have entirely different histories.

92 changed files with 1130 additions and 7895 deletions

View File

@ -1,4 +1,3 @@
VITE_ENABLE_DEMO=false
VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key VITE_SUPABASE_ANON_KEY=your-anon-key
APP_ALLOWED_ORIGINS=http://localhost:5173 APP_ALLOWED_ORIGINS=http://localhost:5173

4
.gitignore vendored
View File

@ -1,9 +1,7 @@
node_modules node_modules
dist dist
.env .env
.env.* .env.local
!.env.example
.DS_Store .DS_Store
.worktrees .worktrees
.superpowers .superpowers
.ruff_cache

24
1
View File

@ -1,24 +0,0 @@
stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the group registry only
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the delivery registry only
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)

View File

@ -1,23 +1,6 @@
:80 :80
root * /usr/share/caddy root * /usr/share/caddy
@static path /assets/* /icons/* /manifest.webmanifest /service-worker.js
handle @static {
file_server file_server
}
handle {
try_files {path} /index.html try_files {path} /index.html
file_server
}
header {
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru wss://supa.supersamsev.ru https://fcm.googleapis.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
X-XSS-Protection "0"
Cross-Origin-Opener-Policy "same-origin"
Service-Worker-Allowed "/"
}

View File

@ -2,14 +2,8 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --prefer-offline RUN npm ci
COPY . . COPY . .
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_SUPABASE_SERVICE_ROLE_KEY
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_SUPABASE_SERVICE_ROLE_KEY=$VITE_SUPABASE_SERVICE_ROLE_KEY
RUN npm run build RUN npm run build
# Serve stage # Serve stage
@ -17,4 +11,3 @@ FROM caddy:2-alpine
COPY --from=build /app/dist /usr/share/caddy COPY --from=build /app/dist /usr/share/caddy
COPY Caddyfile /etc/caddy/Caddyfile COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 80 EXPOSE 80
USER 1000:1000

View File

@ -1,32 +0,0 @@
--- Deploy triggered for refs/heads/main ---
[2026-05-20 13:43:18] Deploy starting...
HEAD is now at 6244749 feat: unify manual_required status and driver label
[2026-05-20 13:43:18] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch main -> FETCH_HEAD
Exit code: 0
=== Deploy dev @ 2026-05-20 13:54:33.475857 ===
[2026-05-20 13:54:33] Dev deploy starting...
HEAD is now at 488e478 Merge pull request 'fix(delivery): simplify public choice flow' (#2) from codex/delivery-rpc-deploy into main
[2026-05-20 13:54:33] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch dev -> FETCH_HEAD
Exit code: 0
=== Deploy main @ 2026-05-20 14:01:43.629298 ===
[2026-05-20 14:01:43] Deploy starting...
HEAD is now at cfb4110 fix: Caddyfile static assets + update package-lock
[2026-05-20 14:01:43] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch main -> FETCH_HEAD
Exit code: 0
=== Deploy main @ 2026-05-20 14:05:34.915793 ===
[2026-05-20 14:05:34] Deploy starting...
HEAD is now at 5a5636c fix: use npm install instead of npm ci for build reliability
[2026-05-20 14:05:35] No changes, skipping rebuild.
From http://10.0.2.2:3000/mihail/supersam
* branch main -> FETCH_HEAD
Exit code: 0

View File

@ -1,30 +0,0 @@
#!/bin/bash
set -e
cd /opt/supersam
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy starting..."
# Pull latest from main
git fetch origin main
BEFORE=$(git rev-parse HEAD)
git reset --hard origin/main
AFTER=$(git rev-parse HEAD)
if [ "$BEFORE" = "$AFTER" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] No changes, skipping rebuild."
exit 0
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Updated: ${BEFORE:0:7} -> ${AFTER:0:7}"
# Rebuild and restart
docker compose -f docker-compose.app.yml up -d --build
# Wait for container to be healthy
sleep 3
if docker ps --format '{{.Names}}' | grep -q 'supersam-app'; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy complete. Container running."
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: Container not running after deploy!"
exit 1
fi

View File

@ -3,10 +3,6 @@ services:
build: build:
context: /opt/supersam context: /opt/supersam
dockerfile: Dockerfile dockerfile: Dockerfile
args:
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
VITE_SUPABASE_SERVICE_ROLE_KEY: ${VITE_SUPABASE_SERVICE_ROLE_KEY:-}
container_name: supersam-app container_name: supersam-app
restart: unless-stopped restart: unless-stopped
networks: networks:
@ -19,15 +15,6 @@ services:
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt - traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
- traefik.http.routers.supersam-app.service=supersam-app - traefik.http.routers.supersam-app.service=supersam-app
- traefik.http.services.supersam-app.loadbalancer.server.port=80 - traefik.http.services.supersam-app.loadbalancer.server.port=80
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
- traefik.http.routers.supersam-app-http.entryPoints=http
- traefik.http.routers.supersam-app-http.middlewares=redirect-to-https
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
- traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin
- traefik.http.routers.supersam-app.middlewares=supersam-sec
networks: networks:
coolify: coolify:

View File

@ -1,241 +0,0 @@
-- Migration: flatten ALL products from source_orders into simple orderItems list
-- This replaces the previous nested orderItems building section.
CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, extensions
AS $$
DECLARE
v_invitation public.delivery_invitations%rowtype;
v_group public.order_groups%rowtype;
v_order record;
v_token_hash text;
v_state text;
v_order_number text;
v_customer_name text;
v_customer_phone text;
v_order_items jsonb;
v_now timestamptz := timezone('utc', now());
BEGIN
IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN
RAISE EXCEPTION 'token is required';
END IF;
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
SELECT *
INTO v_invitation
FROM public.delivery_invitations
WHERE token_hash = v_token_hash;
IF NOT found THEN
RAISE EXCEPTION 'Invitation not found';
END IF;
IF v_invitation.revoked_at IS NOT NULL THEN
RAISE EXCEPTION 'Invitation expired';
END IF;
IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN
RAISE EXCEPTION 'Invitation expired';
END IF;
IF v_invitation.order_group_id IS NOT NULL THEN
SELECT *
INTO v_group
FROM public.order_groups
WHERE id = v_invitation.order_group_id;
IF NOT found THEN
RAISE EXCEPTION 'Order group not found';
END IF;
v_state := CASE
WHEN v_group.delivery_status = 'agreed' THEN 'agreed'
WHEN v_group.delivery_status = 'delivered' THEN 'delivered'
WHEN v_invitation.state IN ('awaiting_choice', 'opened', 'reminder_sent') THEN v_invitation.state
ELSE 'default'
END;
UPDATE public.delivery_invitations
SET
opened_at = CASE
WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now
ELSE opened_at
END,
access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now
WHERE id = v_invitation.id
RETURNING * INTO v_invitation;
v_order_number := COALESCE(
NULLIF(v_invitation.order_number, ''),
to_jsonb(v_group.order_numbers) ->> 0,
NULLIF(v_group.group_key, '')
);
v_customer_name := COALESCE(
NULLIF(v_group.customer_name, ''),
NULLIF(v_invitation.customer_name, '')
);
v_customer_phone := COALESCE(
NULLIF(v_group.customer_phone, ''),
NULLIF(v_group.customer_phone_normalized, ''),
NULLIF(v_invitation.customer_phone, '')
);
-- Build orderItems: flatten ALL products from source_orders into a flat list.
-- Strategy 1: products inside orderList[].items[]
-- Strategy 2: products inside items[] directly on source_orders entry
-- Strategy 3: fallback to invoice names from order_numbers
v_order_items := CASE
WHEN v_group.source_orders IS NOT NULL
AND jsonb_typeof(v_group.source_orders) = 'array'
AND jsonb_array_length(v_group.source_orders) > 0
THEN COALESCE(
-- Strategy 1: flatten products from orderList[].items[]
(SELECT jsonb_agg(
jsonb_build_object(
'name', p ->> 'product_name',
'quantity', COALESCE(NULLIF(p ->> 'product_quantity', ''), ''),
'unit', COALESCE(NULLIF(p ->> 'product_ed', ''), '')
)
)
FROM jsonb_array_elements(v_group.source_orders) AS src,
LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(src -> 'orderList') = 'array' THEN src -> 'orderList'
ELSE '[]'::jsonb
END
) AS so,
LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(so -> 'items') = 'array' THEN so -> 'items'
ELSE '[]'::jsonb
END
) AS p
WHERE p ->> 'product_name' IS NOT NULL
AND p ->> 'product_name' != ''),
-- Strategy 2: products inside items[] directly on source_orders entry
(SELECT jsonb_agg(
jsonb_build_object(
'name', p ->> 'product_name',
'quantity', COALESCE(NULLIF(p ->> 'product_quantity', ''), ''),
'unit', COALESCE(NULLIF(p ->> 'product_ed', ''), '')
)
)
FROM jsonb_array_elements(v_group.source_orders) AS src,
LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(src -> 'items') = 'array' THEN src -> 'items'
ELSE '[]'::jsonb
END
) AS p
WHERE p ->> 'product_name' IS NOT NULL
AND p ->> 'product_name' != ''
AND NOT (p ? 'nom' AND p ? 'items')), -- exclude sub-order entries
-- Strategy 3: fallback to invoice names
(SELECT jsonb_agg(jsonb_build_object('name', on_num, 'quantity', ''))
FROM jsonb_array_elements_text(
CASE
WHEN jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' THEN to_jsonb(v_group.order_numbers)
ELSE '[]'::jsonb
END
) AS on_num),
'[]'::jsonb
)
ELSE COALESCE(
(SELECT jsonb_agg(jsonb_build_object('name', order_number, 'quantity', ''))
FROM jsonb_array_elements_text(
CASE
WHEN jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' THEN to_jsonb(v_group.order_numbers)
ELSE '[]'::jsonb
END
) AS order_number),
'[]'::jsonb
)
END;
RETURN jsonb_build_object(
'ok', TRUE,
'invitation', jsonb_build_object(
'orderId', COALESCE(v_invitation.order_group_id, v_group.id)::text,
'orderGroupId', COALESCE(v_invitation.order_group_id, v_group.id)::text,
'state', v_state,
'token', p_token,
'orderNumber', v_order_number,
'customerName', v_customer_name,
'customerPhone', v_customer_phone,
'orderItems', v_order_items,
'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time,
'orderStatus', NULL,
'deliveryAgreementStatus', NULL
)
);
END IF;
SELECT id, order_number, status, delivery_agreement_status, customer
INTO v_order
FROM public.orders
WHERE id = v_invitation.order_id;
IF NOT found THEN
RAISE EXCEPTION 'Order not found';
END IF;
v_state := CASE v_order.status
WHEN 'Ожидает ответа клиента' THEN 'awaiting_choice'
WHEN 'Ожидает согласования доставки' THEN 'opened'
WHEN 'Напоминание отправлено' THEN 'reminder_sent'
WHEN 'Переход отправлен' THEN 'reminder_sent'
WHEN 'Передан логисту' THEN 'transferred_to_logistics'
WHEN 'Платное хранение' THEN 'paid_storage'
WHEN 'Доставлен' THEN 'delivered'
WHEN 'Доставка согласована' THEN 'agreed'
ELSE 'default'
END;
UPDATE public.delivery_invitations
SET
opened_at = CASE
WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now
ELSE opened_at
END,
access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now
WHERE id = v_invitation.id
RETURNING * INTO v_invitation;
v_order_items := CASE
WHEN jsonb_typeof(v_order.customer -> 'items') = 'array' THEN v_order.customer -> 'items'
ELSE '[]'::jsonb
END;
RETURN jsonb_build_object(
'ok', TRUE,
'invitation', jsonb_build_object(
'orderId', v_invitation.order_id::text,
'state', v_state,
'token', p_token,
'orderNumber', COALESCE(NULLIF(v_order.order_number, ''), NULLIF(v_invitation.order_number, '')),
'customerName', COALESCE(NULLIF(v_order.customer ->> 'name', ''), NULLIF(v_invitation.customer_name, '')),
'customerPhone', COALESCE(NULLIF(v_order.customer ->> 'phone', ''), NULLIF(v_invitation.customer_phone, '')),
'orderItems', v_order_items,
'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time,
'orderStatus', v_order.status,
'deliveryAgreementStatus', v_order.delivery_agreement_status
)
);
END;
$$;
REVOKE ALL ON FUNCTION public.get_delivery_invitation_by_token(text) FROM public;
GRANT EXECUTE ON FUNCTION public.get_delivery_invitation_by_token(text) TO anon, authenticated;

View File

@ -1,17 +1,12 @@
-- Migration: add source_orders items to get_delivery_invitation_by_token create extension if not exists pgcrypto;
-- This replaces ONLY the orderItems building section for the group path.
-- Apply AFTER the base function is restored.
-- Step 1: First restore the original function (run restore-rpc-original.sql if needed) create or replace function public.get_delivery_invitation_by_token(p_token text)
-- Step 2: Then run this migration returns jsonb
language plpgsql
CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text) security definer
RETURNS jsonb set search_path = public, extensions
LANGUAGE plpgsql as $$
SECURITY DEFINER declare
SET search_path = public, extensions
AS $$
DECLARE
v_invitation public.delivery_invitations%rowtype; v_invitation public.delivery_invitations%rowtype;
v_group public.order_groups%rowtype; v_group public.order_groups%rowtype;
v_order record; v_order record;
@ -23,275 +18,260 @@ DECLARE
v_order_items jsonb; v_order_items jsonb;
v_order_numbers jsonb; v_order_numbers jsonb;
v_now timestamptz := timezone('utc', now()); v_now timestamptz := timezone('utc', now());
BEGIN begin
IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN if nullif(trim(coalesce(p_token, '')), '') is null then
RAISE EXCEPTION 'token is required'; raise exception 'token is required';
END IF; end if;
v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
SELECT * select *
INTO v_invitation into v_invitation
FROM public.delivery_invitations from public.delivery_invitations
WHERE token_hash = v_token_hash; where token_hash = v_token_hash;
IF NOT found THEN if not found then
RAISE EXCEPTION 'Invitation not found'; raise exception 'Invitation not found';
END IF; end if;
IF v_invitation.revoked_at IS NOT NULL THEN if v_invitation.revoked_at is not null then
RAISE EXCEPTION 'Invitation expired'; raise exception 'Invitation expired';
END IF; end if;
IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then
RAISE EXCEPTION 'Invitation expired'; raise exception 'Invitation expired';
END IF; end if;
IF v_invitation.order_group_id IS NOT NULL THEN if v_invitation.order_group_id is not null then
SELECT * select *
INTO v_group into v_group
FROM public.order_groups from public.order_groups
WHERE id = v_invitation.order_group_id; where id = v_invitation.order_group_id;
IF NOT found THEN if not found then
RAISE EXCEPTION 'Order group not found'; raise exception 'Order group not found';
END IF; end if;
v_state := CASE v_state := case
WHEN v_group.delivery_status = 'agreed' THEN 'agreed' when v_group.delivery_status = 'agreed' then 'agreed'
WHEN v_group.delivery_status = 'delivered' THEN 'delivered' when v_group.delivery_status = 'delivered' then 'delivered'
WHEN v_invitation.state IN ('awaiting_choice', 'opened', 'reminder_sent') THEN v_invitation.state when v_invitation.state in ('awaiting_choice', 'opened', 'reminder_sent') then v_invitation.state
ELSE 'default' else 'default'
END; end;
UPDATE public.delivery_invitations update public.delivery_invitations
SET set
opened_at = CASE opened_at = case
WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now
ELSE opened_at else opened_at
END, end,
access_count = COALESCE(access_count, 0) + 1, access_count = coalesce(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
WHERE id = v_invitation.id where id = v_invitation.id
RETURNING * INTO v_invitation; returning * into v_invitation;
v_order_number := COALESCE( v_order_number := coalesce(
NULLIF(v_invitation.order_number, ''), nullif(v_invitation.order_number, ''),
to_jsonb(v_group.order_numbers) ->> 0, to_jsonb(v_group.order_numbers) ->> 0,
NULLIF(v_group.group_key, '') nullif(v_group.group_key, '')
); );
v_customer_name := COALESCE( v_customer_name := coalesce(
NULLIF(v_group.customer_name, ''), nullif(v_group.customer_name, ''),
NULLIF(v_invitation.customer_name, '') nullif(v_group.customer ->> 'name', ''),
nullif(v_invitation.customer_name, '')
); );
v_customer_phone := COALESCE( v_customer_phone := coalesce(
NULLIF(v_group.customer_phone, ''), nullif(v_group.customer_phone, ''),
NULLIF(v_group.customer_phone_normalized, ''), nullif(v_group.customer ->> 'phone', ''),
NULLIF(v_invitation.customer_phone, '') nullif(v_invitation.customer_phone, '')
); );
select coalesce(
-- Build orderItems: use source_orders for real product lines if available, jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
-- otherwise fall back to invoice numbers from order_numbers.
v_order_items := CASE
WHEN v_group.source_orders IS NOT NULL
AND jsonb_typeof(v_group.source_orders) = 'array'
AND jsonb_array_length(v_group.source_orders) > 0
THEN COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'name', COALESCE(src ->> 'nom', src ->> 'name', ''),
'quantity', '',
'items', COALESCE(src -> 'orderList', src -> 'items', '[]'::jsonb)
)
) FROM jsonb_array_elements(v_group.source_orders) AS src),
'[]'::jsonb '[]'::jsonb
) )
ELSE COALESCE( into v_order_items
(SELECT jsonb_agg(jsonb_build_object('name', onum, 'quantity', '')) from jsonb_array_elements_text(
FROM unnest(v_group.order_numbers) AS onum case
WHERE onum IS NOT NULL AND onum <> ''), when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers)
'[]'::jsonb else '[]'::jsonb
) end
END; ) as order_number;
RETURN jsonb_build_object( return jsonb_build_object(
'ok', TRUE, 'ok', true,
'invitation', jsonb_build_object( 'invitation', jsonb_build_object(
'orderId', COALESCE(v_invitation.order_group_id, v_group.id)::text, 'orderId', coalesce(v_invitation.order_group_id, v_group.id)::text,
'orderGroupId', COALESCE(v_invitation.order_group_id, v_group.id)::text, 'orderGroupId', coalesce(v_invitation.order_group_id, v_group.id)::text,
'state', v_state, 'state', v_state,
'token', p_token, 'token', p_token,
'orderNumber', v_order_number, 'orderNumber', v_order_number,
'customerName', v_customer_name, 'customerName', v_customer_name,
'customerPhone', v_customer_phone, 'customerPhone', v_customer_phone,
'orderItems', v_order_items, 'orderItems', v_order_items,
'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
'deliveryDate', v_invitation.delivery_date, 'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time, 'deliveryTime', v_invitation.delivery_time,
'orderStatus', NULL, 'orderStatus', null,
'deliveryAgreementStatus', NULL 'deliveryAgreementStatus', null
) )
); );
END IF; end if;
SELECT id, order_number, status, delivery_agreement_status, customer select id, order_number, status, delivery_agreement_status, customer
INTO v_order into v_order
FROM public.orders from public.orders
WHERE id = v_invitation.order_id; where id = v_invitation.order_id;
IF NOT found THEN if not found then
RAISE EXCEPTION 'Order not found'; raise exception 'Order not found';
END IF; end if;
v_state := CASE v_order.status v_state := case v_order.status
WHEN 'Ожидает ответа клиента' THEN 'awaiting_choice' when 'Ожидает ответа клиента' then 'awaiting_choice'
WHEN 'Ожидает согласования доставки' THEN 'opened' when 'Ожидает согласования доставки' then 'opened'
WHEN 'Напоминание отправлено' THEN 'reminder_sent' when 'Напоминание отправлено' then 'reminder_sent'
WHEN 'Переход отправлен' THEN 'reminder_sent' when 'Переход отправлен' then 'reminder_sent'
WHEN 'Передан логисту' THEN 'transferred_to_logistics' when 'Передан логисту' then 'transferred_to_logistics'
WHEN 'Платное хранение' THEN 'paid_storage' when 'Платное хранение' then 'paid_storage'
WHEN 'Доставлен' THEN 'delivered' when 'Доставлен' then 'delivered'
WHEN 'Доставка согласована' THEN 'agreed' when 'Доставка согласована' then 'agreed'
ELSE 'default' else 'default'
END; end;
UPDATE public.delivery_invitations update public.delivery_invitations
SET set
opened_at = CASE opened_at = case
WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now
ELSE opened_at else opened_at
END, end,
access_count = COALESCE(access_count, 0) + 1, access_count = coalesce(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
WHERE id = v_invitation.id where id = v_invitation.id
RETURNING * INTO v_invitation; returning * into v_invitation;
v_order_items := CASE v_order_items := case
WHEN jsonb_typeof(v_order.customer -> 'items') = 'array' THEN v_order.customer -> 'items' when jsonb_typeof(v_order.customer -> 'items') = 'array' then v_order.customer -> 'items'
ELSE '[]'::jsonb else '[]'::jsonb
END; end;
RETURN jsonb_build_object( return jsonb_build_object(
'ok', TRUE, 'ok', true,
'invitation', jsonb_build_object( 'invitation', jsonb_build_object(
'orderId', v_invitation.order_id::text, 'orderId', v_invitation.order_id::text,
'state', v_state, 'state', v_state,
'token', p_token, 'token', p_token,
'orderNumber', COALESCE(NULLIF(v_order.order_number, ''), NULLIF(v_invitation.order_number, '')), 'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')),
'customerName', COALESCE(NULLIF(v_order.customer ->> 'name', ''), NULLIF(v_invitation.customer_name, '')), 'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')),
'customerPhone', COALESCE(NULLIF(v_order.customer ->> 'phone', ''), NULLIF(v_invitation.customer_phone, '')), 'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')),
'orderItems', v_order_items, 'orderItems', v_order_items,
'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
'deliveryDate', v_invitation.delivery_date, 'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time, 'deliveryTime', v_invitation.delivery_time,
'orderStatus', v_order.status, 'orderStatus', v_order.status,
'deliveryAgreementStatus', v_order.delivery_agreement_status 'deliveryAgreementStatus', v_order.delivery_agreement_status
) )
); );
END; end;
$$; $$;
REVOKE ALL ON FUNCTION public.get_delivery_invitation_by_token(text) FROM public; create or replace function public.confirm_delivery_choice_by_token(
GRANT EXECUTE ON FUNCTION public.get_delivery_invitation_by_token(text) TO anon, authenticated;
CREATE OR REPLACE FUNCTION public.confirm_delivery_choice_by_token(
p_token text, p_token text,
p_delivery_date date, p_delivery_date date,
p_delivery_time text p_delivery_time text
) )
RETURNS jsonb returns jsonb
LANGUAGE plpgsql language plpgsql
SECURITY DEFINER security definer
SET search_path = public, extensions set search_path = public, extensions
AS $$ as $$
DECLARE declare
v_invitation public.delivery_invitations%rowtype; v_invitation public.delivery_invitations%rowtype;
v_group public.order_groups%rowtype; v_group public.order_groups%rowtype;
v_order record; v_order record;
v_token_hash text; v_token_hash text;
v_slot_label text; v_slot_label text;
v_now timestamptz := timezone('utc', now()); v_now timestamptz := timezone('utc', now());
BEGIN begin
IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN if nullif(trim(coalesce(p_token, '')), '') is null then
RAISE EXCEPTION 'token is required'; raise exception 'token is required';
END IF; end if;
IF p_delivery_date IS NULL OR nullif(trim(coalesce(p_delivery_time, '')), '') IS NULL THEN if p_delivery_date is null or nullif(trim(coalesce(p_delivery_time, '')), '') is null then
RAISE EXCEPTION 'Selected slot is not available'; raise exception 'Selected slot is not available';
END IF; end if;
v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time)); v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time));
SELECT * select *
INTO v_invitation into v_invitation
FROM public.delivery_invitations from public.delivery_invitations
WHERE token_hash = v_token_hash where token_hash = v_token_hash
FOR UPDATE; for update;
IF NOT found THEN if not found then
RAISE EXCEPTION 'Invitation not found'; raise exception 'Invitation not found';
END IF; end if;
IF v_invitation.revoked_at IS NOT NULL THEN if v_invitation.revoked_at is not null then
RAISE EXCEPTION 'Invitation expired'; raise exception 'Invitation expired';
END IF; end if;
IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then
RAISE EXCEPTION 'Invitation expired'; raise exception 'Invitation expired';
END IF; end if;
IF v_invitation.state NOT IN ('awaiting_choice', 'opened', 'reminder_sent') THEN if v_invitation.state not in ('awaiting_choice', 'opened', 'reminder_sent') then
RAISE EXCEPTION 'Invitation is no longer active'; raise exception 'Invitation is no longer active';
END IF; end if;
IF cardinality(v_invitation.available_slots) > 0 AND NOT (v_slot_label = ANY(v_invitation.available_slots)) THEN if cardinality(v_invitation.available_slots) > 0 and not (v_slot_label = any(v_invitation.available_slots)) then
RAISE EXCEPTION 'Selected slot is not available'; raise exception 'Selected slot is not available';
END IF; end if;
IF v_invitation.order_group_id IS NOT NULL THEN if v_invitation.order_group_id is not null then
SELECT * select *
INTO v_group into v_group
FROM public.order_groups from public.order_groups
WHERE id = v_invitation.order_group_id where id = v_invitation.order_group_id
FOR UPDATE; for update;
IF NOT found THEN if not found then
RAISE EXCEPTION 'Order group not found'; raise exception 'Order group not found';
END IF; end if;
IF v_group.delivery_status <> 'pending_confirmation' THEN if v_group.delivery_status <> 'pending_confirmation' then
RAISE EXCEPTION 'Invitation is no longer active'; raise exception 'Invitation is no longer active';
END IF; end if;
UPDATE public.delivery_invitations update public.delivery_invitations
SET set
state = 'agreed', state = 'agreed',
delivery_date = p_delivery_date, delivery_date = p_delivery_date,
delivery_time = trim(p_delivery_time), delivery_time = trim(p_delivery_time),
confirmed_at = v_now, confirmed_at = v_now,
access_count = COALESCE(access_count, 0) + 1, access_count = coalesce(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
WHERE id = v_invitation.id; where id = v_invitation.id;
UPDATE public.order_groups update public.order_groups
SET set
delivery_status = 'agreed', delivery_status = 'agreed',
delivery_date = p_delivery_date, delivery_date = p_delivery_date,
delivery_time = trim(p_delivery_time), delivery_time = trim(p_delivery_time),
notification_status = 'confirmed', notification_status = 'confirmed',
updated_at = v_now updated_at = v_now
WHERE id = v_group.id; where id = v_group.id;
INSERT INTO public.integration_events ( insert into public.integration_events (
order_id, order_id,
event_type, event_type,
direction, direction,
status, status,
payload payload
) )
VALUES ( values (
NULL, null,
'delivery_choice_confirmed', 'delivery_choice_confirmed',
'inbound', 'inbound',
'success', 'success',
@ -303,66 +283,66 @@ BEGIN
) )
); );
RETURN jsonb_build_object( return jsonb_build_object(
'ok', TRUE, 'ok', true,
'orderGroupId', v_group.id, 'orderGroupId', v_group.id,
'deliveryStatus', 'agreed' 'deliveryStatus', 'agreed'
); );
END IF; end if;
SELECT id, status, delivery_agreement_status select id, status, delivery_agreement_status
INTO v_order into v_order
FROM public.orders from public.orders
WHERE id = v_invitation.order_id where id = v_invitation.order_id
FOR UPDATE; for update;
IF NOT found THEN if not found then
RAISE EXCEPTION 'Order not found'; raise exception 'Order not found';
END IF; end if;
IF v_order.status NOT IN ('Ожидает ответа клиента', 'Ожидает согласования доставки') THEN if v_order.status not in ('Ожидает ответа клиента', 'Ожидает согласования доставки') then
RAISE EXCEPTION 'Invitation is no longer active'; raise exception 'Invitation is no longer active';
END IF; end if;
UPDATE public.delivery_invitations update public.delivery_invitations
SET set
state = 'agreed', state = 'agreed',
delivery_date = p_delivery_date, delivery_date = p_delivery_date,
delivery_time = trim(p_delivery_time), delivery_time = trim(p_delivery_time),
confirmed_at = v_now, confirmed_at = v_now,
access_count = COALESCE(access_count, 0) + 1, access_count = coalesce(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
WHERE id = v_invitation.id; where id = v_invitation.id;
UPDATE public.orders update public.orders
SET set
status = 'Доставка согласована', status = 'Доставка согласована',
delivery_agreement_status = 'Подтверждено клиентом' delivery_agreement_status = 'Подтверждено клиентом'
WHERE id = v_order.id; where id = v_order.id;
INSERT INTO public.delivery_slots ( insert into public.delivery_slots (
order_id, order_id,
delivery_date, delivery_date,
delivery_time, delivery_time,
logistician_id, logistician_id,
status status
) )
VALUES ( values (
v_order.id, v_order.id,
p_delivery_date, p_delivery_date,
trim(p_delivery_time), trim(p_delivery_time),
NULL, null,
'confirmed_by_client' 'confirmed_by_client'
); );
INSERT INTO public.order_history ( insert into public.order_history (
order_id, order_id,
action, action,
old_status, old_status,
new_status, new_status,
metadata metadata
) )
VALUES ( values (
v_order.id, v_order.id,
'Подтверждение выбора доставки клиентом', 'Подтверждение выбора доставки клиентом',
v_order.status, v_order.status,
@ -375,14 +355,14 @@ BEGIN
) )
); );
INSERT INTO public.integration_events ( insert into public.integration_events (
order_id, order_id,
event_type, event_type,
direction, direction,
status, status,
payload payload
) )
VALUES ( values (
v_order.id, v_order.id,
'delivery_choice_confirmed', 'delivery_choice_confirmed',
'inbound', 'inbound',
@ -393,14 +373,17 @@ BEGIN
) )
); );
RETURN jsonb_build_object( return jsonb_build_object(
'ok', TRUE, 'ok', true,
'orderId', v_order.id, 'orderId', v_order.id,
'status', 'Доставка согласована', 'status', 'Доставка согласована',
'deliveryAgreementStatus', 'Подтверждено клиентом' 'deliveryAgreementStatus', 'Подтверждено клиентом'
); );
END; end;
$$; $$;
REVOKE ALL ON FUNCTION public.confirm_delivery_choice_by_token(text, date, text) FROM public; revoke all on function public.get_delivery_invitation_by_token(text) from public;
GRANT EXECUTE ON FUNCTION public.confirm_delivery_choice_by_token(text, date, text) TO anon, authenticated; grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated;
revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public;
grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated;

View File

@ -1,199 +0,0 @@
-- Migration: add source_orders items to get_delivery_invitation_by_token
-- This replaces ONLY the orderItems building section for the group path.
-- Apply AFTER the base function is restored.
-- Step 1: First restore the original function (run restore-rpc-original.sql if needed)
-- Step 2: Then run this migration
CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, extensions
AS $$
DECLARE
v_invitation public.delivery_invitations%rowtype;
v_group public.order_groups%rowtype;
v_order record;
v_token_hash text;
v_state text;
v_order_number text;
v_customer_name text;
v_customer_phone text;
v_order_items jsonb;
v_order_numbers jsonb;
v_now timestamptz := timezone('utc', now());
BEGIN
IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN
RAISE EXCEPTION 'token is required';
END IF;
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
SELECT *
INTO v_invitation
FROM public.delivery_invitations
WHERE token_hash = v_token_hash;
IF NOT found THEN
RAISE EXCEPTION 'Invitation not found';
END IF;
IF v_invitation.revoked_at IS NOT NULL THEN
RAISE EXCEPTION 'Invitation expired';
END IF;
IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN
RAISE EXCEPTION 'Invitation expired';
END IF;
IF v_invitation.order_group_id IS NOT NULL THEN
SELECT *
INTO v_group
FROM public.order_groups
WHERE id = v_invitation.order_group_id;
IF NOT found THEN
RAISE EXCEPTION 'Order group not found';
END IF;
v_state := CASE
WHEN v_group.delivery_status = 'agreed' THEN 'agreed'
WHEN v_group.delivery_status = 'delivered' THEN 'delivered'
WHEN v_invitation.state IN ('awaiting_choice', 'opened', 'reminder_sent') THEN v_invitation.state
ELSE 'default'
END;
UPDATE public.delivery_invitations
SET
opened_at = CASE
WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now
ELSE opened_at
END,
access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now
WHERE id = v_invitation.id
RETURNING * INTO v_invitation;
v_order_number := COALESCE(
NULLIF(v_invitation.order_number, ''),
to_jsonb(v_group.order_numbers) ->> 0,
NULLIF(v_group.group_key, '')
);
v_customer_name := COALESCE(
NULLIF(v_group.customer_name, ''),
NULLIF(v_invitation.customer_name, '')
);
v_customer_phone := COALESCE(
NULLIF(v_group.customer_phone, ''),
NULLIF(v_group.customer_phone_normalized, ''),
NULLIF(v_invitation.customer_phone, '')
);
-- Build orderItems: use source_orders for real product lines if available,
-- otherwise fall back to invoice numbers from order_numbers.
v_order_items := CASE
WHEN v_group.source_orders IS NOT NULL
AND jsonb_typeof(v_group.source_orders) = 'array'
AND jsonb_array_length(v_group.source_orders) > 0
THEN COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'name', COALESCE(src ->> 'nom', src ->> 'name', ''),
'quantity', '',
'items', COALESCE(src -> 'orderList', src -> 'items', '[]'::jsonb)
)
) FROM jsonb_array_elements(v_group.source_orders) AS src),
'[]'::jsonb
)
ELSE COALESCE(
(SELECT jsonb_agg(jsonb_build_object('name', order_number, 'quantity', ''))
FROM jsonb_array_elements_text(
CASE
WHEN jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' THEN to_jsonb(v_group.order_numbers)
ELSE '[]'::jsonb
END
) AS order_number),
'[]'::jsonb
)
END;
RETURN jsonb_build_object(
'ok', TRUE,
'invitation', jsonb_build_object(
'orderId', COALESCE(v_invitation.order_group_id, v_group.id)::text,
'orderGroupId', COALESCE(v_invitation.order_group_id, v_group.id)::text,
'state', v_state,
'token', p_token,
'orderNumber', v_order_number,
'customerName', v_customer_name,
'customerPhone', v_customer_phone,
'orderItems', v_order_items,
'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time,
'orderStatus', NULL,
'deliveryAgreementStatus', NULL
)
);
END IF;
SELECT id, order_number, status, delivery_agreement_status, customer
INTO v_order
FROM public.orders
WHERE id = v_invitation.order_id;
IF NOT found THEN
RAISE EXCEPTION 'Order not found';
END IF;
v_state := CASE v_order.status
WHEN 'Ожидает ответа клиента' THEN 'awaiting_choice'
WHEN 'Ожидает согласования доставки' THEN 'opened'
WHEN 'Напоминание отправлено' THEN 'reminder_sent'
WHEN 'Переход отправлен' THEN 'reminder_sent'
WHEN 'Передан логисту' THEN 'transferred_to_logistics'
WHEN 'Платное хранение' THEN 'paid_storage'
WHEN 'Доставлен' THEN 'delivered'
WHEN 'Доставка согласована' THEN 'agreed'
ELSE 'default'
END;
UPDATE public.delivery_invitations
SET
opened_at = CASE
WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now
ELSE opened_at
END,
access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now
WHERE id = v_invitation.id
RETURNING * INTO v_invitation;
v_order_items := CASE
WHEN jsonb_typeof(v_order.customer -> 'items') = 'array' THEN v_order.customer -> 'items'
ELSE '[]'::jsonb
END;
RETURN jsonb_build_object(
'ok', TRUE,
'invitation', jsonb_build_object(
'orderId', v_invitation.order_id::text,
'state', v_state,
'token', p_token,
'orderNumber', COALESCE(NULLIF(v_order.order_number, ''), NULLIF(v_invitation.order_number, '')),
'customerName', COALESCE(NULLIF(v_order.customer ->> 'name', ''), NULLIF(v_invitation.customer_name, '')),
'customerPhone', COALESCE(NULLIF(v_order.customer ->> 'phone', ''), NULLIF(v_invitation.customer_phone, '')),
'orderItems', v_order_items,
'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time,
'orderStatus', v_order.status,
'deliveryAgreementStatus', v_order.delivery_agreement_status
)
);
END;
$$;
REVOKE ALL ON FUNCTION public.get_delivery_invitation_by_token(text) FROM public;
GRANT EXECUTE ON FUNCTION public.get_delivery_invitation_by_token(text) TO anon, authenticated;

View File

@ -13,7 +13,6 @@
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" /> <link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Construction Delivery Control</title> <title>Construction Delivery Control</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

256
package-lock.json generated
View File

@ -8,15 +8,14 @@
"name": "construction-delivery", "name": "construction-delivery",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "2.52.0", "@supabase/supabase-js": "^2.52.0",
"clsx": "2.1.1", "clsx": "^2.1.1",
"date-fns": "4.1.0", "date-fns": "^4.1.0",
"framer-motion": "12.7.4", "framer-motion": "^12.7.4",
"playwright": "1.60.0", "react": "^18.3.1",
"react": "18.3.1", "react-dom": "^18.3.1",
"react-dom": "18.3.1", "react-router-dom": "^7.3.0",
"react-router-dom": "7.3.0", "tailwind-merge": "^3.3.0"
"tailwind-merge": "3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
@ -78,6 +77,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -1426,78 +1426,83 @@
] ]
}, },
"node_modules/@supabase/auth-js": { "node_modules/@supabase/auth-js": {
"version": "2.71.1", "version": "2.99.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.0.tgz",
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", "integrity": "sha512-tHiIST/OEoLmWBE+3X69xRY5srJM/lL86KltmMlIfDo9ePJLo14vQQV9T4NF+P+MoGhCwQL1GTmk51zuAFMXKw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/node-fetch": "^2.6.14" "tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
} }
}, },
"node_modules/@supabase/functions-js": { "node_modules/@supabase/functions-js": {
"version": "2.4.5", "version": "2.99.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.0.tgz",
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", "integrity": "sha512-zA9oad6EqGwMLLu2LfP1bXbqKcJGiotAdbdTfZG7YS7619YZQAEgejj9mp+E5vglKE1yMWbKK+S1J3PbuUtgLg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/node-fetch": "^2.6.14" "tslib": "2.8.1"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
}, },
"engines": { "engines": {
"node": "4.x || >=6.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@supabase/postgrest-js": { "node_modules/@supabase/postgrest-js": {
"version": "1.19.4", "version": "2.99.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.0.tgz",
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", "integrity": "sha512-8qfOMi2pu9y0IQhUAeFqjrvR49G4ELGevXCWV9qAHXFQ/h2FFh0I8PYjFQj4rHcHSq6hrpozDnS1vbQU8NAQ/A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/node-fetch": "^2.6.14" "tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
} }
}, },
"node_modules/@supabase/realtime-js": { "node_modules/@supabase/realtime-js": {
"version": "2.11.15", "version": "2.99.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.15.tgz", "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.0.tgz",
"integrity": "sha512-HQKRnwAqdVqJW/P9TjKVK+/ETpW4yQ8tyDPPtRMKOH4Uh3vQD74vmj353CYs8+YwVBKubeUOOEpI9CT8mT4obw==", "integrity": "sha512-7nFTZhNeANR7FvEY6PfWLCfE8dHqcaJd9SuR7IPEZvBPG9K4uEHMivpjZx4NWRSU7Eji7ZbKy2LG+cJ48DhwHg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/node-fetch": "^2.6.13",
"@types/phoenix": "^1.6.6", "@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"isows": "^1.0.7", "tslib": "2.8.1",
"ws": "^8.18.2" "ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
} }
}, },
"node_modules/@supabase/storage-js": { "node_modules/@supabase/storage-js": {
"version": "2.7.1", "version": "2.99.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.0.tgz",
"integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", "integrity": "sha512-mAEEbfsght5EEALejYrwAP9k8sFBGjfMZT8n4SyMXk2iYuWVeRMs1kA/uKg0uDMctWdZ0bL+L4jZzksUJpCjMA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/node-fetch": "^2.6.14" "iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
} }
}, },
"node_modules/@supabase/supabase-js": { "node_modules/@supabase/supabase-js": {
"version": "2.52.0", "version": "2.99.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.52.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.0.tgz",
"integrity": "sha512-jbs3CV1f2+ge7sgBeEduboT9v/uGjF22v0yWi/5/XFn5tbM8MfWRccsMtsDwAwu24XK8H6wt2LJDiNnZLtx/bg==", "integrity": "sha512-SP9Sn9tsHDB7N4u2gT13rdeZJewE4xibAxasG7vOz+fYi92+XkMMbWNx0uGK53zKTnAnvTs16isRooyBy4sn5w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/auth-js": "2.71.1", "@supabase/auth-js": "2.99.0",
"@supabase/functions-js": "2.4.5", "@supabase/functions-js": "2.99.0",
"@supabase/node-fetch": "2.6.15", "@supabase/postgrest-js": "2.99.0",
"@supabase/postgrest-js": "1.19.4", "@supabase/realtime-js": "2.99.0",
"@supabase/realtime-js": "2.11.15", "@supabase/storage-js": "2.99.0"
"@supabase/storage-js": "2.7.1" },
"engines": {
"node": ">=20.0.0"
} }
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
@ -1556,12 +1561,6 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -1611,6 +1610,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1777,6 +1777,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2150,6 +2151,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -2887,6 +2889,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3257,13 +3260,13 @@
} }
}, },
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.7.4", "version": "12.35.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.7.4.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.2.tgz",
"integrity": "sha512-jX0bPsTmU0oPZTYz/dVyD0dmOyEOEJvdn0TaZBE5I8g2GvVnnQnW9f65cJnoVfUkY3WZWNXGXnPbVA9YnaIfVA==", "integrity": "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-dom": "^12.7.4", "motion-dom": "^12.35.2",
"motion-utils": "^12.7.2", "motion-utils": "^12.29.2",
"tslib": "^2.4.0" "tslib": "^2.4.0"
}, },
"peerDependencies": { "peerDependencies": {
@ -3566,6 +3569,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -4037,21 +4049,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/isows": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
"integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/iterator.prototype": { "node_modules/iterator.prototype": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@ -4076,6 +4073,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@ -4694,50 +4692,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -4768,6 +4722,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -4990,6 +4945,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@ -5002,6 +4958,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@ -5028,15 +4985,13 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.3.0", "version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw==", "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "^2.6.0"
"turbo-stream": "2.4.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@ -5052,12 +5007,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.3.0", "version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.3.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ==", "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.3.0" "react-router": "7.13.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@ -5702,9 +5657,9 @@
} }
}, },
"node_modules/tailwind-merge": { "node_modules/tailwind-merge": {
"version": "3.3.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==", "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@ -5848,6 +5803,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5898,12 +5854,6 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -5917,12 +5867,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -6093,6 +6037,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@ -6209,6 +6154,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -6302,22 +6248,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -6451,9 +6381,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.1", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@ -12,16 +12,14 @@
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs" "anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "2.52.0", "@supabase/supabase-js": "^2.52.0",
"clsx": "2.1.1", "clsx": "^2.1.1",
"date-fns": "4.1.0", "date-fns": "^4.1.0",
"framer-motion": "12.7.4", "framer-motion": "^12.7.4",
"playwright": "1.60.0", "react": "^18.3.1",
"react": "18.3.1", "react-dom": "^18.3.1",
"react-dom": "18.3.1", "react-router-dom": "^7.3.0",
"react-router-dom": "7.3.0", "tailwind-merge": "^3.3.0"
"tailwind-merge": "3.3.0",
"recharts": "^2.15.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",

View File

@ -1,7 +1,7 @@
{ {
"name": "SuperSam Доставка", "name": "Школьное питание",
"short_name": "SuperSam", "short_name": "Школьное питание",
"description": "Панель управления доставкой стройматериалов с уведомлениями.", "description": "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера.",
"start_url": "/dashboard", "start_url": "/dashboard",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",

View File

@ -1,8 +1,5 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; const STATIC_CACHE = "construction-delivery-static-v1";
const RUNTIME_CACHE = "construction-delivery-runtime-v1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v4";
const RUNTIME_CACHE = "construction-delivery-runtime-v4";
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"]; const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
@ -76,50 +73,3 @@ if (!isLocalhost) {
}), }),
); );
}); });
} else {
self.addEventListener("install", (event) => self.skipWaiting());
self.addEventListener("activate", (event) => self.clients.claim());
}
// Push notification handler
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = { title: "Уведомление", body: "" };
}
const title = data.title || "Уведомление";
const options = {
body: data.body || "",
icon: data.icon || "/icons/icon-192.svg",
badge: data.badge || "/icons/icon-192.svg",
data: data.data || {},
tag: data.tag || "default",
vibrate: [100, 50, 100],
requireInteraction: data.requireInteraction || false,
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const clickData = event.notification.data || {};
const targetUrl = clickData.order_id
? "/dashboard/group/" + clickData.order_id
: "/dashboard";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes("/dashboard") && "focus" in client) {
return client.focus();
}
}
return self.clients.openWindow(targetUrl);
}),
);
});

View File

@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""SuperSam daily backup — export public tables to CSV, tar.gz, upload to S3."""
import csv
import io
import os
import sys
import subprocess
import tarfile
import tempfile
from datetime import datetime
import boto3
from botocore.config import Config
# S3 config
S3_ENDPOINT = "https://s3.ru1.storage.beget.cloud"
S3_KEY = "YG4MQNKAPNL65200MBUY"
S3_SECRET = "8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_BUCKET = "02f162ff4a18-supersam-s3"
# DB config
DB_HOST = "supabase-db"
DB_USER = "supabase_admin"
DB_NAME = "postgres"
def get_tables():
"""Get list of public tables with data."""
result = subprocess.run(
["docker", "exec", "-i", DB_HOST, "psql", "-U", DB_USER, "-d", DB_NAME,
"-t", "-A", "-c",
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"ERROR getting tables: {result.stderr}", file=sys.stderr)
sys.exit(1)
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
return tables
def export_table_csv(table_name, out_dir):
"""Export a single table to CSV via psql \\copy."""
csv_path = os.path.join(out_dir, f"{table_name}.csv")
# Use psql \copy (client-side) to avoid needing superuser for COPY
cmd = [
"docker", "exec", "-i", DB_HOST,
"psql", "-U", DB_USER, "-d", DB_NAME,
"-c", f"\\copy (SELECT * FROM public.{table_name}) TO '/tmp/{table_name}.csv' WITH CSV HEADER;"
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"WARN: {table_name} export failed: {result.stderr}", file=sys.stderr)
return False
# Copy from container to host
cmd2 = ["docker", "cp", f"{DB_HOST}:/tmp/{table_name}.csv", csv_path]
result2 = subprocess.run(cmd2, capture_output=True, text=True)
if result2.returncode != 0:
print(f"WARN: {table_name} docker cp failed: {result2.stderr}", file=sys.stderr)
return False
# Clean up temp file in container
subprocess.run(["docker", "exec", "-i", DB_HOST, "rm", "-f", f"/tmp/{table_name}.csv"],
capture_output=True, text=True)
# Check if file has data (more than just header)
with open(csv_path, "r") as f:
lines = f.readlines()
if len(lines) <= 1:
print(f" {table_name}: empty (skipping)")
return False
print(f" {table_name}: {len(lines)-1} rows")
return True
def upload_to_s3(file_path, key):
"""Upload file to S3."""
s3 = boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_KEY,
aws_secret_access_key=S3_SECRET,
config=Config(signature_version="s3v4"),
region_name="ru-1"
)
s3.upload_file(file_path, S3_BUCKET, key)
print(f" Uploaded: s3://{S3_BUCKET}/{key}")
def main():
date_str = datetime.now().strftime("%Y-%m-%d")
archive_name = f"supersam-backup-{date_str}.tar.gz"
s3_key = f"backups/{archive_name}"
print(f"=== SuperSam Backup {date_str} ===")
with tempfile.TemporaryDirectory() as tmpdir:
tables = get_tables()
print(f"Found {len(tables)} tables")
exported = []
for table in tables:
if export_table_csv(table, tmpdir):
exported.append(table)
if not exported:
print("No tables with data — nothing to backup")
sys.exit(0)
# Create tar.gz
archive_path = os.path.join(tmpdir, archive_name)
with tarfile.open(archive_path, "w:gz") as tar:
for table in exported:
csv_path = os.path.join(tmpdir, f"{table}.csv")
tar.add(csv_path, arcname=f"{table}.csv")
# Get size
size_mb = os.path.getsize(archive_path) / (1024 * 1024)
print(f"Archive: {archive_name} ({size_mb:.2f} MB)")
# Upload
upload_to_s3(archive_path, s3_key)
print(f"=== Backup complete ===")
if __name__ == "__main__":
main()

View File

@ -1,80 +0,0 @@
import React from 'react';
import { logError } from '../utils/errorLogger';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Extract component stack for richer context
const componentInfo = {
component: errorInfo?.componentStack || null,
props: this.props,
};
logError(error, componentInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
renderDefaultFallback() {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
textAlign: 'center',
minHeight: '200px',
}}
>
<h2 style={{ marginBottom: '0.5rem', color: '#e53e3e' }}>
Something went wrong
</h2>
<p style={{ marginBottom: '1rem', color: '#718096', fontSize: '0.9rem' }}>
An unexpected error occurred. You can try again.
</p>
<button
onClick={this.handleRetry}
style={{
padding: '0.5rem 1.25rem',
fontSize: '0.9rem',
fontWeight: 600,
color: '#fff',
backgroundColor: '#3182ce',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Try Again
</button>
</div>
);
}
render() {
if (this.state.hasError) {
// Allow custom fallback render function
if (typeof this.props.fallback === 'function') {
return this.props.fallback(this.state.error, this.handleRetry);
}
return this.renderDefaultFallback();
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -10,7 +10,6 @@ export const Badge = ({ children, tone = "neutral", className }) => {
"border-[rgba(18,128,92,0.18)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent", "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(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-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
"border-[rgba(33,111,191,0.22)] bg-[rgba(33,111,191,0.12)] text-[#216fbf]": tone === "info",
"border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral", "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral",
}, },
className, className,

View File

@ -1,250 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Panel } from "./Panel";
const MONTH_NAMES = [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
];
const WEEKDAY_SHORT = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
function getDaysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfWeek(year, month) {
const day = new Date(year, month, 1).getDay();
return day === 0 ? 6 : day - 1; // Monday = 0
}
function formatDateISO(date) {
if (!date) return "";
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function formatDateDisplay(date) {
if (!date) return "";
return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`;
}
function parseDateFromISO(str) {
if (!str) return null;
const [y, m, d] = str.split("-").map(Number);
if (!y || !m || !d) return null;
return new Date(y, m - 1, d);
}
export const DatePicker = ({
value,
onChange,
placeholder = "Выберите дату",
className = "",
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [viewYear, setViewYear] = useState(() => {
const d = value ? parseDateFromISO(value) : new Date();
return d.getFullYear();
});
const [viewMonth, setViewMonth] = useState(() => {
const d = value ? parseDateFromISO(value) : new Date();
return d.getMonth();
});
const containerRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const handle = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handle);
return () => document.removeEventListener("pointerdown", handle);
}, [isOpen]);
useEffect(() => {
const handle = (e) => {
if (e.key === "Escape" && isOpen) setIsOpen(false);
};
document.addEventListener("keydown", handle);
return () => document.removeEventListener("keydown", handle);
}, [isOpen]);
const selectedDate = value ? parseDateFromISO(value) : null;
const handleDayClick = useCallback(
(day) => {
const d = new Date(viewYear, viewMonth, day);
onChange(formatDateISO(d));
setIsOpen(false);
},
[viewYear, viewMonth, onChange]
);
const handleClear = useCallback(
(e) => {
e.stopPropagation();
onChange("");
},
[onChange]
);
const prevMonth = () => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear((y) => y - 1);
} else {
setViewMonth((m) => m - 1);
}
};
const nextMonth = () => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear((y) => y + 1);
} else {
setViewMonth((m) => m + 1);
}
};
const goToToday = () => {
const now = new Date();
setViewYear(now.getFullYear());
setViewMonth(now.getMonth());
onChange(formatDateISO(now));
setIsOpen(false);
};
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfWeek(viewYear, viewMonth);
const today = new Date();
const isToday = (day) =>
day === today.getDate() &&
viewMonth === today.getMonth() &&
viewYear === today.getFullYear();
const isSelected = (day) =>
selectedDate &&
day === selectedDate.getDate() &&
viewMonth === selectedDate.getMonth() &&
viewYear === selectedDate.getFullYear();
const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
return (
<div ref={containerRef} className={`relative ${className}`}>
{label && (
<span className="mb-1 block text-xs font-semibold text-[var(--color-text-muted)]">
{label}
</span>
)}
<button
type="button"
onClick={() => setIsOpen((v) => !v)}
className={[
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
isOpen
? "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(" ")}
>
<span className={value ? "" : "text-[var(--color-text-muted)]"}>
{value ? formatDateDisplay(selectedDate) : placeholder}
</span>
<span className="ml-2 flex items-center gap-1">
{value && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
className="text-[var(--color-text-muted)] hover:text-[var(--color-danger)] text-xs"
>
</span>
)}
<span className="text-[var(--color-text-muted)]">📅</span>
</span>
</button>
{isOpen && (
<div
className="absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
style={{ minWidth: "280px" }}
>
{/* Header: month/year nav */}
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<button
type="button"
onClick={prevMonth}
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm"
>
</button>
<span className="text-sm font-semibold text-[var(--color-text)]">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
<button
type="button"
onClick={nextMonth}
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm"
>
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 px-2 pt-2">
{WEEKDAY_SHORT.map((wd) => (
<div
key={wd}
className="flex h-8 items-center justify-center text-xs font-semibold text-[var(--color-text-muted)]"
>
{wd}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7 gap-0 px-2 pb-1">
{cells.map((day, i) => (
<button
key={i}
type="button"
disabled={!day}
onClick={day ? () => handleDayClick(day) : undefined}
className={[
"flex h-9 w-full items-center justify-center rounded-xl text-sm transition",
!day
? ""
: isSelected(day)
? "bg-[var(--color-accent)] text-white font-bold shadow-sm"
: isToday(day)
? "border border-[var(--color-accent)] text-[var(--color-accent)] font-semibold"
: "text-[var(--color-text)] hover:bg-[var(--color-accent-soft)]",
].join(" ")}
>
{day || ""}
</button>
))}
</div>
{/* Today button */}
<div className="border-t border-[var(--color-border)] px-2 py-2">
<button
type="button"
onClick={goToToday}
className="w-full rounded-xl py-2 text-xs font-semibold text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition"
>
Сегодня
</button>
</div>
</div>
)}
</div>
);
};

View File

@ -1,50 +0,0 @@
import React from "react";
const Icon = ({ children, className = "" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{children}
</svg>
);
export const Bell = (props) => (
<Icon {...props}>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</Icon>
);
export const Settings = (props) => (
<Icon {...props}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</Icon>
);
export const Check = (props) => (
<Icon {...props}>
<path d="M20 6 9 17l-5-5" />
</Icon>
);
export const CheckCheck = (props) => (
<Icon {...props}>
<path d="M18 6 7 17l-5-5" />
<path d="m22 6-11 11-2-2" />
</Icon>
);
export const X = (props) => (
<Icon {...props}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</Icon>
);

View File

@ -5,7 +5,7 @@ export const Panel = ({ children, className, ...props }) => {
return ( return (
<section <section
className={cn( className={cn(
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft", "rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft backdrop-blur",
className, className,
)} )}
{...props} {...props}

View File

@ -1,76 +0,0 @@
import React from "react";
/**
* Compact PWA install button for the header.
* - Shows 📲 icon when install prompt is available (Chrome/Edge on Android & desktop).
* - Shows iOS Safari instructions tooltip on click when on iOS.
* - Hidden when app is already installed (standalone mode).
* - After install: auto-hidden via appinstalled event.
*/
export const PwaInstallButton = ({ onInstall, isInstalled, isInstallAvailable }) => {
const [showTip, setShowTip] = React.useState(false);
const tipRef = React.useRef(null);
// Detect iOS (no beforeinstallprompt, but can be added to home screen)
const isIOS = typeof navigator !== "undefined" && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
const handleInstallClick = async () => {
if (isInstallAvailable && onInstall) {
await onInstall();
} else {
// Show instruction tip for iOS or unsupported browsers
setShowTip((prev) => !prev);
}
};
// Close tip on outside click
React.useEffect(() => {
if (!showTip) return;
const handler = (e) => {
if (tipRef.current && !tipRef.current.contains(e.target)) {
setShowTip(false);
}
};
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}, [showTip]);
// Don't render if already installed as PWA AFTER all hooks
if (isInstalled) return null;
return (
<div className="relative inline-flex" ref={tipRef}>
<button
type="button"
onClick={handleInstallClick}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-base transition hover:bg-[var(--color-accent-soft)]"
aria-label="Установить приложение"
title="Установить приложение"
>
📲
</button>
{showTip && (
<div className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm shadow-lg">
{isIOS ? (
<>
<p className="font-medium">Установка на iOS</p>
<ol className="mt-1.5 list-decimal pl-4 leading-relaxed text-[var(--color-text-muted)]">
<li>Откройте в <strong>Safari</strong></li>
<li>Нажмите <strong>Поделиться</strong> </li>
<li>Выберите <strong>«На экран Домой»</strong></li>
</ol>
</>
) : (
<>
<p className="font-medium">Установка</p>
<p className="mt-1 leading-relaxed text-[var(--color-text-muted)]">
Нажмите значок 📲 в адресной строке браузера или используйте меню <strong>«Установить приложение»</strong>.
</p>
</>
)}
</div>
)}
</div>
);
};

View File

@ -1,357 +0,0 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
import { safeSupabaseCall } from "../../services/safeSupabaseCall";
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
const ACTIONS = [
"status_change",
"driver_assigned",
"driver_removed",
"date_assigned",
"client_confirmed",
"client_cancelled",
"cancelled",
"manual_confirmation",
"paid_storage",
"sms_sent",
"invitation_created",
];
const ACTION_TONES = {
status_change: "accent",
driver_assigned: "info",
driver_removed: "warning",
date_assigned: "info",
client_confirmed: "success",
client_cancelled: "danger",
cancelled: "danger",
manual_confirmation: "success",
paid_storage: "warning",
sms_sent: "accent",
invitation_created: "accent",
};
const ROLE_LABELS = {
mega_admin: "Мега-админ",
admin: "Админ",
manager: "Менеджер",
logistician: "Логист",
driver: "Водитель",
};
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const formatMSKCorrect = (isoStr) => {
if (!isoStr) return "—";
try {
const d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr;
const msk = new Date(d.getTime() + 3 * 60 * 60 * 1000);
const day = String(msk.getUTCDate()).padStart(2, "0");
const month = String(msk.getUTCMonth() + 1).padStart(2, "0");
const year = msk.getUTCFullYear();
const hours = String(msk.getUTCHours()).padStart(2, "0");
const mins = String(msk.getUTCMinutes()).padStart(2, "0");
return `${day}.${month}.${year} ${hours}:${mins}`;
} catch {
return isoStr;
}
};
export const ActionLogPanel = ({ orderGroupId = null }) => {
const { user } = useAuth();
const navigate = useNavigate();
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [filterAction, setFilterAction] = useState("");
const [filterDateFrom, setFilterDateFrom] = useState("");
const [filterDateTo, setFilterDateTo] = useState("");
const [filterSearch, setFilterSearch] = useState("");
const [expandedId, setExpandedId] = useState(null);
const [userNames, setUserNames] = useState({});
// Fetch user name map for resolving UUIDs
useEffect(() => {
const fetchNames = async () => {
if (!hasSupabaseConfig || !supabase) return;
const result = await safeSupabaseCall(
async () => {
const { data, error } = await supabase.from("users").select("id, name");
if (error) throw error;
return data;
},
"Ошибка загрузки пользователей"
);
if (result && !result.error) {
const map = {};
(Array.isArray(result) ? result : result.data || []).forEach((u) => {
map[u.id] = u.name;
});
setUserNames(map);
}
};
fetchNames();
}, []);
const resolveName = useCallback(
(uuid) => {
if (!uuid || !UUID_RE.test(uuid)) return uuid;
return userNames[uuid] || uuid;
},
[userNames]
);
const loadLogs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchActionLogs({
orderGroupId,
action: filterAction || null,
dateFrom: filterDateFrom || null,
dateTo: filterDateTo || null,
limit: 500,
});
if (result.error) {
setError(result.error);
} else {
setLogs(result.data || result || []);
}
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [orderGroupId, filterAction, filterDateFrom, filterDateTo]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
const filteredLogs = useMemo(() => {
if (!filterSearch) return logs;
const q = filterSearch.toLowerCase();
return logs.filter((log) =>
(log.performer_name || "").toLowerCase().includes(q) ||
(log.action || "").toLowerCase().includes(q) ||
(getActionLabel(log.action) || "").toLowerCase().includes(q) ||
(log.old_value || "").toLowerCase().includes(q) ||
(log.new_value || "").toLowerCase().includes(q) ||
(log.order_group_id || "").toLowerCase().includes(q) ||
(getActionDescription(log) || "").toLowerCase().includes(q) ||
(log.details?.driver_name || "").toLowerCase().includes(q)
);
}, [logs, filterSearch]);
/** Human-readable description */
const getActionDescription = (log) => {
switch (log.action) {
case "status_change": {
const oldVal = resolveName(log.old_value) || "—";
const newVal = resolveName(log.new_value) || "—";
return `${oldVal}${newVal}`;
}
case "driver_assigned": {
const name = log.details?.driver_name || resolveName(log.new_value) || "водитель";
return `Назначен: ${name}`;
}
case "driver_removed": {
const name = log.details?.driver_name || resolveName(log.old_value) || "водитель";
return `Снят: ${name}`;
}
case "date_assigned":
return `Дата: ${log.new_value || "—"}`;
case "client_confirmed":
return "Клиент подтвердил";
case "client_cancelled":
return "Клиент отменил";
case "cancelled":
return "Отменено";
case "manual_confirmation":
return "Ручное подтверждение";
case "paid_storage":
return "Платное хранение";
case "sms_sent":
return "SMS отправлено";
case "invitation_created":
return "Приглашение создано";
default:
return log.new_value || getActionLabel(log.action);
}
};
const getActionDesc = getActionDescription;
return (
<Panel className="space-y-4 p-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Журнал действий</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Кто, что и когда делал с доставками
</p>
</div>
<button
onClick={loadLogs}
disabled={loading}
className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{loading ? "Загрузка..." : "Обновить"}
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
placeholder="Поиск..."
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none focus:border-[var(--color-accent)] min-w-[160px]"
/>
<select
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none"
>
<option value="">Все действия</option>
{ACTIONS.map((a) => (
<option key={a} value={a}>{getActionLabel(a)}</option>
))}
</select>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none"
title="С"
/>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm outline-none"
title="По"
/>
</div>
{error && (
<div className="rounded-[14px] border border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-3 text-sm text-[var(--color-danger)]">
{error}
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]">
<th className="pb-2 pr-3 font-medium">Дата/Время</th>
<th className="pb-2 pr-3 font-medium">Сотрудник</th>
<th className="pb-2 pr-3 font-medium">Действие</th>
<th className="pb-2 pr-3 font-medium">Описание</th>
{!orderGroupId && <th className="pb-2 pr-3 font-medium">Группа</th>}
</tr>
</thead>
<tbody>
{filteredLogs.length === 0 && !loading && (
<tr>
<td colSpan={orderGroupId ? 4 : 5} className="py-6 text-center text-[var(--color-text-muted)]">
Нет записей
</td>
</tr>
)}
{filteredLogs.map((log) => (
<React.Fragment key={log.id}>
<tr
className="border-b border-[var(--color-border)] cursor-pointer hover:bg-[var(--color-surface-strong)]"
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
>
<td className="py-2 pr-3 whitespace-nowrap">{formatMSKCorrect(log.performed_at)}</td>
<td className="py-2 pr-3">
<span className="font-medium">{log.performer_name || "Система"}</span>
{log.performer_role && (
<span className="ml-1 text-xs text-[var(--color-text-muted)]">({ROLE_LABELS[log.performer_role] || log.performer_role})</span>
)}
</td>
<td className="py-2 pr-3">
<Badge tone={ACTION_TONES[log.action] || "accent"}>
{getActionLabel(log.action)}
</Badge>
</td>
<td className="py-2 pr-3">
<span className="text-sm">{getActionDesc(log)}</span>
</td>
{!orderGroupId && (
<td className="py-2 pr-3 text-sm">
{log.order_group_id ? (
<a
href={`/dashboard/group/${log.order_group_id}`}
className="text-[var(--color-accent)] hover:underline font-medium"
onClick={(e) => { e.preventDefault(); navigate(`/dashboard/group/${log.order_group_id}`); }}
>
Группа
</a>
) : "—"}
</td>
)}
</tr>
{expandedId === log.id && (() => {
const hasChange = log.old_value && log.new_value && log.old_value !== log.new_value;
const isDriverAction = log.action === "driver_assigned" || log.action === "driver_removed";
const detailEntries = (log.details && typeof log.details === "object")
? Object.entries(log.details).filter(([k]) => k !== "source" && k !== "driver_name" && k !== "driver_id")
: [];
return (
<tr className="bg-[var(--color-surface-strong)]">
<td colSpan={orderGroupId ? 4 : 5} className="py-2 px-3">
<div className="space-y-1 text-xs">
{hasChange && !isDriverAction && (
<div><span className="text-[var(--color-text-muted)]">Было:</span> {resolveName(log.old_value)} <span className="text-[var(--color-text-muted)]">Стало:</span> {resolveName(log.new_value)}</div>
)}
{isDriverAction && log.details?.driver_name && !log.old_value && (
<div><span className="text-[var(--color-text-muted)]">Водитель:</span> {log.details.driver_name}</div>
)}
{isDriverAction && log.old_value && (
<div><span className="text-[var(--color-text-muted)]">Было:</span> {resolveName(log.old_value)} <span className="text-[var(--color-text-muted)]">Стало:</span> {log.details?.driver_name || resolveName(log.new_value)}</div>
)}
{detailEntries.length > 0 && (
<div className="space-y-0.5">
{detailEntries.map(([k, v]) => (
<div key={k}>
<span className="text-[var(--color-text-muted)]">
{{problem_type: "Тип проблемы", delivery_date_source: "Источник даты"}[k] || k}:
</span> {UUID_RE.test(String(v)) ? resolveName(String(v)) : String(v)}
</div>
))}
</div>
)}
{log.order_group_id && (
<div>
<span className="text-[var(--color-text-muted)]">Группа:</span>{" "}
<a href={`/dashboard/group/${log.order_group_id}`}
className="text-[var(--color-accent)] hover:underline"
onClick={(e) => { e.preventDefault(); navigate(`/dashboard/group/${log.order_group_id}`); }}
>Перейти к группе</a>
</div>
)}
</div>
</td>
</tr>
);
})()}
</React.Fragment>
))}
</tbody>
</table>
</div>
{loading && <div className="py-4 text-center text-sm text-[var(--color-text-muted)]">Загрузка...</div>}
</Panel>
);
};

View File

@ -1,332 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid,
} from 'recharts';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { SegmentedTabs } from '../UI/SegmentedTabs';
import { useAdminStats } from '../../hooks/useAdminStats';
const useIsMobile = () => {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
setMobile(mq.matches);
const handler = (e) => setMobile(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return mobile;
};
const STATUS_COLORS = {
pending_confirmation: '#94a3b8',
manual_confirmation_required: '#eab308',
agreed: '#22c55e',
driver_assigned: '#3b82f6',
loaded: '#6366f1',
on_route: '#8b5cf6',
delivered: '#10b981',
paid_storage: '#06b6d4',
problem: '#ef4444',
cancelled: '#64748b',
};
const STATUS_LABELS = {
pending_confirmation: 'Ожидает подтверждения',
manual_confirmation_required: 'Ручное подтверждение',
agreed: 'Согласовано',
driver_assigned: 'Водитель назначен',
loaded: 'Загружено',
on_route: 'В пути',
delivered: 'Доставлено',
paid_storage: 'Оплаченное хранение',
problem: 'Проблема',
cancelled: 'Отменено',
};
const PERIOD_OPTIONS = [
{ key: '1d', label: 'Сегодня' },
{ key: '7d', label: '7 дней' },
{ key: '30d', label: '30 дней' },
{ key: 'all', label: 'Все' },
];
const CustomTooltip = ({ active, payload, label: tooltipLabel }) => {
if (!active || !payload?.length) return null;
return (
<div style={{
background: 'var(--color-surface, #1e293d)',
border: '1px solid var(--color-border, #334155)',
borderRadius: '12px', padding: '8px 12px', fontSize: '0.8rem',
color: 'var(--color-text, #e2e8f0)',
}}>
{tooltipLabel && <div style={{ marginBottom: '4px', fontWeight: 600 }}>{tooltipLabel}</div>}
{payload.map((p, i) => (
<div key={i} style={{ color: p.color }}>{p.name}: <strong>{p.value}</strong></div>
))}
</div>
);
};
export const AdminDashboard = () => {
const [period, setPeriod] = useState('7d');
const mobile = useIsMobile();
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
if (isLoading) {
return (
<Panel>
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted)' }}>Загрузка...</div>
</Panel>
);
}
if (error) {
return (
<Panel>
<div style={{ color: 'var(--color-danger)', padding: '1rem' }}>Ошибка: {error}</div>
</Panel>
);
}
const sv = stats || {};
const totalGroups = sv.total || 0;
const econ = economics || {};
const statusPieData = (statusDist || []).map(s => ({
name: STATUS_LABELS[s.delivery_status] || s.delivery_status,
value: s.count,
status: s.delivery_status,
})).filter(d => d.value > 0);
const trendData = (dailyTrend || []).map(d => ({
date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '',
delivered: d.delivered || 0, total: d.total || 0, problems: d.problems || 0,
}));
const driverData = (driverStats || []).map(d => ({
name: d.driver_name || 'Неизвестный',
total: d.total || 0, delivered: d.delivered || 0, problems: d.problems || 0,
}));
// Funnel: ALWAYS show all steps, even with 0 values
const funnelSteps = [
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
{ label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' },
{ label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' },
{ label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' },
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
];
// Responsive values
const chartHeight = mobile ? 200 : 240;
const kpiMin = mobile ? '80px' : '110px';
const chartGridCols = mobile ? '1fr' : '1fr 2fr';
const driverLabelWidth = mobile ? 80 : 120;
const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' }
: { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
{/* Period selector */}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<div>
<h2 style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p>
</div>
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
</div>
{/* KPI — centered on mobile */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}>
{[
{ label: 'Всего', val: totalGroups },
{ label: 'Ожидает', val: sv.pending },
{ label: 'В работе', val: sv.in_progress },
{ label: 'Доставлено', val: sv.delivered },
{ label: 'Проблемы', val: sv.problem },
{ label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' },
].map((kpi, i) => (
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>{kpi.val ?? '—'}</div>
</Panel>
))}
</div>
{/* Pie + Line — stacked on mobile, side-by-side on desktop */}
<div style={{ display: 'grid', gridTemplateColumns: chartGridCols, gap: mobile ? '0.5rem' : '1rem' }}>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По статусам</h3>
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={chartHeight}>
<PieChart>
<Pie data={statusPieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 40}
outerRadius={mobile ? 60 : 80}
dataKey="value" nameKey="name" paddingAngle={2}>
{statusPieData.map(entry => (
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)' }} />
</PieChart>
</ResponsiveContainer>
)}
</Panel>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Тренд по дням</h3>
{trendData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={chartHeight}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</Panel>
</div>
{/* Status table */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Все статусы</h3>
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div>
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
gap: '0 0.4rem', padding: '0.3rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
color: 'var(--color-text-muted)', fontWeight: 600,
}}>
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
</div>
{statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return (
<div key={s.status} style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
gap: '0 0.4rem', padding: '0.4rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
}}>
<div style={{ width: mobile ? '8px' : '10px', height: mobile ? '8px' : '10px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)' }}>{s.value}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
</div>
);
})}
</div>
)}
</Panel>
{/* Воронка согласования — ALL steps always visible */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>Воронка согласования</h3>
{totalGroups === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 0' }}>
{funnelSteps.map((step, i) => {
const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0;
const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15;
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
<div style={{ fontSize: mobile ? '0.8rem' : '0.85rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
{step.value}
</div>
<div style={{
width: widthPct + '%', height: mobile ? '28px' : '32px', background: step.value > 0 ? step.color : 'var(--color-border, #334155)',
borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%',
opacity: step.value > 0 ? 1 : 0.5,
}}>
<span style={{ fontSize: mobile ? '0.6rem' : '0.7rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
{pct}%
</span>
</div>
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '220px' }}>
{step.label}
</div>
{i < funnelSteps.length - 1 && (
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
)}
</div>
);
})}
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Автосогласование</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#22c55e' }}>{econ.auto_confirm_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#ef4444', marginBottom: '1px' }}>Ручное вмешательство</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#ef4444' }}>{econ.manual_intervention_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Всего согласовано</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: 'var(--color-text)' }}>{econ.confirmed_auto_total ?? 0}</div>
</div>
</div>
</div>
)}
</Panel>
{/* SMS */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>SMS</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
{[
{ label: 'SMS 1', val: econ.sms1_sent_count ?? 0 },
{ label: 'SMS 2', val: econ.sms2_sent_count ?? 0 },
{ label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{item.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{item.val}</div>
</div>
))}
</div>
</Panel>
{/* Drivers */}
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
{driverData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(150, driverData.length * (mobile ? 35 : 45))}>
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 20 }}>
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={driverLabelWidth} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" />
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" />
</BarChart>
</ResponsiveContainer>
)}
</Panel>
</div>
);
};

View File

@ -1,404 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { Select } from '../UI/Select';
import { supabase } from '../../supabaseClient';
const DATE_RANGES = [
{ value: 'today', label: 'Сегодня' },
{ value: '7d', label: '7 дней' },
{ value: '30d', label: '30 дней' },
{ value: 'all', label: 'Всё время' },
];
const ERROR_TONES = {
Error: 'danger',
TypeError: 'warning',
ReferenceError: 'warning',
SyntaxError: 'danger',
RangeError: 'warning',
NetworkError: 'info',
UnhandledRejection: 'danger',
Warning: 'warning',
};
function formatLogEntry(e) {
const ts = e.created_at ? new Date(e.created_at).toISOString() : 'NO_TIMESTAMP';
const lines = [
`[${ts}] ${e.error_type || 'Unknown'}: ${e.message || 'No message'}`,
` URL: ${e.url || '-'}`,
` Component: ${e.component || '-'}`,
` Line: ${e.line_number ?? '-'}:${e.column_number ?? '-'}`,
];
if (e.user_id) lines.push(` User: ${e.user_id}`);
if (e.props) lines.push(` Props: ${e.props}`);
if (e.stack) {
lines.push(' Stack:');
e.stack.split('\n').forEach(l => lines.push(' ' + l));
}
lines.push(` UA: ${e.user_agent || '-'}`);
return lines.join('\n');
}
export default function ErrorLogPanel() {
const [errors, setErrors] = useState([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(null);
const [expandedId, setExpandedId] = useState(null);
const [filterType, setFilterType] = useState('');
const [filterRange, setFilterRange] = useState('7d');
const [availableTypes, setAvailableTypes] = useState([]);
const [selected, setSelected] = useState(new Set());
const [copied, setCopied] = useState(false);
const [deleting, setDeleting] = useState(false);
const intervalRef = useRef(null);
const getRangeStart = (range) => {
const now = new Date();
switch (range) {
case 'today': return new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
case '7d': return new Date(now.getTime() - 7 * 86400000).toISOString();
case '30d': return new Date(now.getTime() - 30 * 86400000).toISOString();
default: return null;
}
};
const fetchErrors = useCallback(async () => {
setFetchError(null);
let query = supabase.from('client_error_logs').select('*').order('created_at', { ascending: false });
const rangeStart = getRangeStart(filterRange);
if (rangeStart) query = query.gte('created_at', rangeStart);
if (filterType) query = query.eq('error_type', filterType);
const { data, error: err } = await query;
if (err) { setFetchError(err.message); setErrors([]); }
else {
setErrors(data || []);
const types = [...new Set((data || []).map((e) => e.error_type).filter(Boolean))].sort();
setAvailableTypes(types);
}
setLoading(false);
}, [filterRange, filterType]);
useEffect(() => { setLoading(true); fetchErrors(); }, [fetchErrors]);
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(fetchErrors, 30000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [fetchErrors]);
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—';
const trunc = (s, n) => !s ? '—' : s.length > n ? s.slice(0, n) + '…' : s;
const toggleSelect = (id) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selected.size === errors.length) {
setSelected(new Set());
} else {
setSelected(new Set(errors.map(e => e.id)));
}
};
const handleCopyAll = async () => {
const text = errors.map(formatLogEntry).join('\n\n');
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopySelected = async () => {
const text = errors.filter(e => selected.has(e.id)).map(formatLogEntry).join('\n\n');
if (!text) return;
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleDeleteSelected = async () => {
if (selected.size === 0) return;
if (!confirm(`Удалить ${selected.size} записей?`)) return;
setDeleting(true);
const { error: err } = await supabase
.from('client_error_logs')
.delete()
.in('id', Array.from(selected));
if (err) { setFetchError('Ошибка удаления: ' + err.message); }
else { setSelected(new Set()); fetchErrors(); }
setDeleting(false);
};
const handleDeleteAll = async () => {
if (!confirm('Удалить ВСЕ записи об ошибках? Это необратимо.')) return;
setDeleting(true);
const { error: err } = await supabase
.from('client_error_logs')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000');
if (err) { setFetchError('Ошибка удаления: ' + err.message); }
else { setSelected(new Set()); fetchErrors(); }
setDeleting(false);
};
const handleDeleteOne = async (id) => {
if (!confirm('Удалить эту запись?')) return;
setDeleting(true);
const { error: err } = await supabase
.from('client_error_logs')
.delete()
.eq('id', id);
if (err) { setFetchError('Ошибка удаления: ' + err.message); }
else { setExpandedId(null); fetchErrors(); }
setDeleting(false);
};
const downloadJSON = () => {
const data = selected.size > 0
? errors.filter(e => selected.has(e.id))
: errors;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `errors_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const downloadCSV = () => {
const data = selected.size > 0
? errors.filter(e => selected.has(e.id))
: errors;
const headers = ['created_at', 'error_type', 'message', 'url', 'component', 'line_number', 'column_number', 'user_id', 'stack', 'props', 'user_agent'];
const escape = (v) => {
const s = v == null ? '' : String(v);
if (s.includes(',') || s.includes('"') || s.includes('\n')) return '"' + s.replace(/"/g, '""') + '"';
return s;
};
const rows = data.map(e => headers.map(h => escape(e[h])).join(','));
const csv = [headers.join(','), ...rows].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `errors_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const btnBase = {
borderRadius: '9999px', padding: '0.4rem 0.75rem', cursor: 'pointer',
fontSize: '0.85rem', fontWeight: 600, border: '1px solid var(--color-border, #334155)',
background: 'transparent', color: 'var(--color-text, #e2e8f0)',
};
const btnDanger = { ...btnBase, borderColor: 'var(--color-danger, #ef4444)', color: 'var(--color-danger, #ef4444)' };
const btnAccent = { ...btnBase, borderColor: 'var(--color-accent, #22c55e)', color: 'var(--color-accent, #22c55e)' };
return (
<Panel>
{/* Toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer', fontSize: '0.85rem' }}>
<input type="checkbox" checked={selected.size === errors.length && errors.length > 0} onChange={toggleSelectAll} />
Все
</label>
<Select value={filterRange} onChange={(e) => setFilterRange(e.target.value)} className="min-w-[100px]! text-sm! py-2!">
{DATE_RANGES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="min-w-[140px]! text-sm! py-2!">
<option value="">Все типы</option>
{availableTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</Select>
<span style={{ color: 'var(--color-text-muted, #94a3b8)', fontSize: '0.85rem' }}>
{errors.length} ошибок {selected.size > 0 && `(${selected.size} выбр.)`}
</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button onClick={fetchErrors} style={btnBase} title="Обновить"></button>
<button onClick={handleCopyAll} disabled={errors.length === 0} style={{ ...btnAccent, opacity: errors.length === 0 ? 0.4 : 1 }}>
📋 Копировать всё
</button>
{selected.size > 0 && (
<button onClick={handleCopySelected} style={btnAccent}>
📋 Копировать выбр.
</button>
)}
<button onClick={downloadJSON} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
JSON
</button>
<button onClick={downloadCSV} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
CSV
</button>
{selected.size > 0 && (
<button onClick={handleDeleteSelected} disabled={deleting} style={{ ...btnDanger, opacity: deleting ? 0.5 : 1 }}>
🗑 Удалить выбр.
</button>
)}
<button onClick={handleDeleteAll} disabled={deleting || errors.length === 0} style={{ ...btnDanger, opacity: deleting || errors.length === 0 ? 0.4 : 1 }}>
🗑 Удалить все
</button>
</div>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted, #94a3b8)', marginBottom: '0.5rem' }}>
Автообновление каждые 30 сек
</div>
{fetchError && (
<div style={{
background: 'rgba(201,61,61,0.12)', color: 'var(--color-danger, #ef4444)',
borderRadius: '12px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.9rem',
}}>
{fetchError}
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>Загрузка</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
{errors.length === 0 && (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>
Нет ошибок за выбранный период
</div>
)}
{errors.map((err) => {
const isExpanded = expandedId === err.id;
const isSelected = selected.has(err.id);
return (
<div
key={err.id}
style={{ borderBottom: '1px solid var(--color-border, #334155)' }}
>
<div
style={{
display: 'grid',
gridTemplateColumns: '2rem 1.4fr 1fr 2.5fr 1.5fr',
gap: '0.5rem',
alignItems: 'center',
padding: '0.55rem 0.75rem',
fontSize: '0.88rem',
cursor: 'pointer',
background: isSelected ? 'rgba(34,197,94,0.08)' : 'transparent',
}}
onClick={() => setExpandedId(isExpanded ? null : err.id)}
>
<input
type="checkbox"
checked={isSelected}
onClick={(e) => e.stopPropagation()}
onChange={() => toggleSelect(err.id)}
/>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)', whiteSpace: 'nowrap' }}>
{fmtDate(err.created_at)}
</span>
<Badge tone={ERROR_TONES[err.error_type] || 'neutral'}>
{err.error_type || 'Unknown'}
</Badge>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={err.message}>
{trunc(err.message, 120)}
</span>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={err.url}>
{trunc(err.url, 40)}
</span>
</div>
{isExpanded && (
<div style={{
padding: '0.75rem 1rem 1rem',
background: 'var(--color-surface-strong, #1e293b)',
fontSize: '0.85rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}>
<div>
<strong>Сообщение:</strong>
<pre style={{
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'monospace', fontSize: '0.82rem',
}}>
{err.message || '—'}
</pre>
</div>
<div>
<strong>Стек:</strong>
<pre style={{
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'monospace', fontSize: '0.82rem',
background: 'var(--color-surface, #0f172a)', padding: '0.5rem',
borderRadius: '8px', border: '1px solid var(--color-border, #334155)',
maxHeight: '250px', overflow: 'auto',
}}>
{err.stack || '—'}
</pre>
</div>
<div>
<strong>Props:</strong>
<pre style={{
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'monospace', fontSize: '0.82rem',
background: 'var(--color-surface, #0f172a)', padding: '0.5rem',
borderRadius: '8px', border: '1px solid var(--color-border, #334155)',
maxHeight: '180px', overflow: 'auto',
}}>
{err.props || '—'}
</pre>
</div>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem',
fontSize: '0.82rem', color: 'var(--color-text-muted, #94a3b8)',
}}>
<div><strong>URL:</strong> {err.url || '—'}</div>
<div><strong>Компонент:</strong> {err.component || '—'}</div>
<div><strong>Строка:</strong> {err.line_number ?? '—'}:{err.column_number ?? '—'}</div>
<div><strong>UA:</strong> {trunc(err.user_agent, 60)}</div>
{err.user_id && <div style={{ gridColumn: '1 / -1' }}><strong>User ID:</strong> {err.user_id}</div>}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => handleDeleteOne(err.id)} disabled={deleting} style={{ ...btnDanger, fontSize: '0.8rem' }}>
Удалить запись
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{copied && (
<div style={{
position: 'fixed', bottom: '1rem', right: '1rem',
background: 'var(--color-accent, #22c55e)', color: '#fff',
padding: '0.5rem 1rem', borderRadius: '9999px', fontSize: '0.85rem',
fontWeight: 600, zIndex: 1000,
}}>
Скопировано!
</div>
)}
</Panel>
);
}

View File

@ -1,126 +0,0 @@
import React from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { supabase } from "../../supabaseClient";
export const StopWordsPanel = () => {
const [words, setWords] = React.useState([]);
const [newWord, setNewWord] = React.useState("");
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [deletingId, setDeletingId] = React.useState(null);
const loadWords = React.useCallback(async () => {
setIsLoading(true);
setError("");
const { data, error: fetchError } = await supabase
.from("stop_words")
.select("id, word, created_at")
.order("word", { ascending: true });
if (fetchError) {
setError(fetchError.message);
} else {
setWords(data || []);
}
setIsLoading(false);
}, []);
React.useEffect(() => { loadWords(); }, [loadWords]);
const handleAdd = async () => {
const trimmed = newWord.trim().toLowerCase();
if (!trimmed) return;
if (words.some((w) => w.word === trimmed)) {
setError("Такое слово уже есть");
return;
}
setError("");
const { error: insertError } = await supabase
.from("stop_words")
.insert({ word: trimmed });
if (insertError) {
setError(insertError.message);
return;
}
setNewWord("");
await loadWords();
};
const handleDelete = async (id) => {
setDeletingId(id);
const { error: deleteError } = await supabase
.from("stop_words")
.delete()
.eq("id", id);
if (deleteError) {
setError(deleteError.message);
} else {
await loadWords();
}
setDeletingId(null);
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
};
return (
<Panel className="space-y-5 p-6">
<div>
<h2 className="text-lg font-semibold">Стоп-слова</h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Позиции с этими словами не показываются клиентам в карточке доставки.
Добавляйте слова-маркеры: «сверление», «обмер» и т.д.
</p>
</div>
<div className="flex gap-3">
<input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Новое стоп-слово"
className="flex-1 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 text-sm !text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
maxLength={100}
/>
<Button onClick={handleAdd} disabled={!newWord.trim()}>
Добавить
</Button>
</div>
{error && (
<p className="text-sm text-[var(--color-warning)]">{error}</p>
)}
{isLoading ? (
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
) : !words.length ? (
<p className="text-sm text-[var(--color-text-muted)]">Стоп-слов пока нет. Добавьте первое.</p>
) : (
<div className="flex flex-wrap gap-2">
{words.map((w) => (
<span
key={w.id}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-3 py-1.5 text-sm !text-[var(--color-text)]"
>
{w.word}
<button
type="button"
disabled={deletingId === w.id}
onClick={() => handleDelete(w.id)}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:!text-[var(--color-danger)] disabled:opacity-40"
aria-label={`Удалить ${w.word}`}
>
×
</button>
</span>
))}
</div>
)}
</Panel>
);
};

View File

@ -8,7 +8,7 @@ import { Panel } from "../UI/Panel";
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
export const UserDirectoryPanel = ({ currentUser, users }) => { export const UserDirectoryPanel = ({ currentUser, users }) => {
if (currentUser.role !== "admin" && currentUser.role !== "mega_admin") { if (currentUser.role !== "admin") {
return ( return (
<Panel className="p-5"> <Panel className="p-5">
<h3 className="text-lg font-semibold">Пользователи и роли</h3> <h3 className="text-lg font-semibold">Пользователи и роли</h3>

View File

@ -1,392 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { Input } from '../UI/Input';
import { supabase, supabaseUrl } from '../../supabaseClient';
const ROLES = ['admin', 'driver', 'logistician', 'manager', 'mega_admin'];
const ROLE_LABELS = {
mega_admin: 'Суперадмин',
admin: 'Администратор',
manager: 'Менеджер',
logistician: 'Логист',
driver: 'Водитель-экспедитор',
};
const ROLE_TONES = {
mega_admin: 'danger',
admin: 'warning',
manager: 'info',
logistician: 'accent',
driver: 'accent',
};
/* ── Call manage-users edge function ── */
async function adminApi(method, body) {
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
if (!token) throw new Error('Не авторизован');
const opts = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
},
};
if (body && method !== 'DELETE') opts.body = JSON.stringify(body);
const url = method === 'DELETE' && body?.id
? `${supabaseUrl}/functions/v1/manage-users?id=${body.id}`
: `${supabaseUrl}/functions/v1/manage-users`;
const res = await fetch(url, opts);
const json = await res.json();
if (!res.ok) throw new Error(json.error || `Ошибка ${res.status}`);
return json;
}
/* ── Custom dropdown (matches app design system, full-width) ── */
function RoleDropdown({ value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('pointerdown', onDown); document.removeEventListener('keydown', onKey); };
}, [open]);
const pick = (role) => { onChange(role); setOpen(false); };
return (
<div ref={ref} className="relative w-full">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={[
'flex w-full h-[46px] items-center justify-between gap-2 rounded-2xl border px-4 text-sm transition hover:border-[var(--color-accent)]',
value ? 'border-[var(--color-border)] bg-[var(--color-surface-strong)]' : 'border-[var(--color-border)] bg-[var(--color-surface-strong)]',
].join(' ')}
>
{value ? (
<Badge tone={ROLE_TONES[value] || 'neutral'}>{ROLE_LABELS[value]}</Badge>
) : (
<span className="text-[var(--color-text-muted)]">Выберите роль</span>
)}
<span className="text-[var(--color-text-muted)] text-xs"></span>
</button>
{open && (
<div className="absolute left-0 right-0 top-full z-50 mt-1.5 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft">
{ROLES.map((r) => {
const sel = r === value;
return (
<button
key={r}
type="button"
onClick={() => pick(r)}
className={[
'flex w-full items-center justify-between px-4 py-3 text-left text-sm transition',
sel ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : 'text-[var(--color-text)] hover:bg-[var(--color-surface)]',
].join(' ')}
>
<span>{ROLE_LABELS[r]}</span>
{sel && <span className="text-[var(--color-accent)]"></span>}
</button>
);
})}
</div>
)}
</div>
);
}
/* ── Add-user modal ── */
function AddUserModal({ onSubmit, onClose, submitting, error }) {
const [form, setForm] = useState({ name: '', email: '', role: '' });
const nameRef = useRef(null);
useEffect(() => { nameRef.current?.focus(); }, []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-[400px] max-w-[92vw] rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-6 shadow-panel"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-5 text-lg font-semibold text-[var(--color-text)]">Новый пользователь</h3>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(form);
}}
className="space-y-4"
>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--color-text-muted)]">Имя</label>
<Input
ref={nameRef}
placeholder="Иван Петров"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--color-text-muted)]">Email</label>
<Input
placeholder="ivan@company.ru"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--color-text-muted)]">Роль</label>
<RoleDropdown
value={form.role}
onChange={(r) => setForm((f) => ({ ...f, role: r }))}
/>
</div>
{error && (
<div className="rounded-xl bg-[rgba(201,61,61,0.12)] px-4 py-2.5 text-sm text-[var(--color-danger)]">{error}</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-full border border-[var(--color-border)] px-4 py-2.5 text-sm font-semibold text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] transition"
>
Отмена
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 rounded-full bg-[var(--color-accent)] px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition disabled:opacity-50"
>
{submitting ? 'Добавление…' : 'Добавить'}
</button>
</div>
</form>
</div>
</div>
);
}
export default function UserManagementPanel() {
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({ name: '', email: '', role: '' });
const [saving, setSaving] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const [addSubmitting, setAddSubmitting] = useState(false);
const [addError, setAddError] = useState(null);
const fetchRoles = useCallback(async () => {
const { data, error: err } = await supabase.from('roles').select('id, name').order('name');
if (err) { setError(err.message); return []; }
setRoles(data || []);
return data || [];
}, []);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
const { data, error: err } = await supabase
.from('users')
.select('id, email, name, role_id, created_at, last_login, roles(name)')
.order('created_at', { ascending: false });
if (err) { setError(err.message); setUsers([]); }
else setUsers(data || []);
setLoading(false);
}, []);
useEffect(() => { fetchRoles(); fetchUsers(); }, [fetchRoles, fetchUsers]);
const getRoleName = (user) => {
if (user.roles?.name) return user.roles.name;
const match = roles.find((r) => r.id === user.role_id);
return match ? match.name : 'unknown';
};
const getRoleId = (roleName) => {
const match = roles.find((r) => r.name === roleName);
return match ? match.id : null;
};
/* ── Add via edge function ── */
const handleAddUser = async (form) => {
setAddError(null);
if (!form.name || !form.email || !form.role) { setAddError('Все поля обязательны.'); return; }
setAddSubmitting(true);
try {
await adminApi('POST', { email: form.email, name: form.name, role: form.role });
await fetchUsers();
setShowAddForm(false);
} catch (err) {
setAddError(err.message || 'Не удалось добавить пользователя.');
} finally { setAddSubmitting(false); }
};
/* ── Edit via edge function ── */
const startEdit = (user) => {
setEditingId(user.id);
setEditForm({ name: user.name || '', email: user.email || '', role: getRoleName(user) });
setDeleteConfirmId(null);
};
const cancelEdit = () => { setEditingId(null); setEditForm({ name: '', email: '', role: '' }); };
const saveEdit = async () => {
setSaving(true);
try {
const roleId = getRoleId(editForm.role);
if (!roleId) throw new Error('Неизвестная роль: ' + editForm.role);
const { error: err } = await supabase
.from('users')
.update({ name: editForm.name, email: editForm.email, role_id: roleId })
.eq('id', editingId);
if (err) throw err;
setEditingId(null);
await fetchUsers();
} catch (err) { setError(err.message || 'Не удалось сохранить.'); }
finally { setSaving(false); }
};
/* ── Delete via edge function ── */
const handleDeleteUser = async (userId) => {
try {
await adminApi('DELETE', { id: userId });
setDeleteConfirmId(null);
await fetchUsers();
} catch (err) { setError(err.message || 'Не удалось удалить.'); }
};
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—';
if (loading) {
return <Panel className="p-5"><div className="text-center py-8 text-[var(--color-text-muted)]">Загрузка</div></Panel>;
}
return (
<Panel>
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 flex-wrap mb-4">
<span className="text-sm text-[var(--color-text-muted)]">{users.length} пользователей</span>
<button
onClick={() => { setShowAddForm(true); setAddError(null); }}
className="rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white hover:opacity-90 transition"
>
+ Добавить
</button>
</div>
{/* Add user modal */}
{showAddForm && (
<AddUserModal
onSubmit={handleAddUser}
onClose={() => { setShowAddForm(false); setAddError(null); }}
submitting={addSubmitting}
error={addError}
/>
)}
{/* Error */}
{error && (
<div className="mb-4 rounded-xl bg-[rgba(201,61,61,0.12)] px-4 py-3 text-sm text-[var(--color-danger)]">
{error}
</div>
)}
{/* User list */}
<div className="space-y-2">
{users.length === 0 && (
<div className="py-8 text-center text-[var(--color-text-muted)]">Нет пользователей</div>
)}
{users.map((user) => {
const rn = getRoleName(user);
const isEditing = editingId === user.id;
if (isEditing) {
return (
<div
key={user.id}
className="rounded-[22px] border border-[var(--color-accent)] bg-[var(--color-surface-strong)] p-4 space-y-2"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
placeholder="Имя"
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
className="w-[200px]!"
/>
<Input
placeholder="Email"
type="email"
value={editForm.email}
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
className="w-[240px]!"
/>
<RoleDropdown
value={editForm.role}
onChange={(r) => setEditForm((f) => ({ ...f, role: r }))}
/>
</div>
<div className="flex gap-2">
<button
onClick={cancelEdit}
className="rounded-lg border border-[var(--color-border)] px-3 py-1.5 text-sm text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] transition"
>Отмена</button>
<button
onClick={saveEdit}
disabled={saving}
className="rounded-full bg-[var(--color-accent)] px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 transition disabled:opacity-50"
>{saving ? 'Сохранение…' : 'Сохранить'}</button>
</div>
</div>
);
}
return (
<div
key={user.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3"
>
<div className="min-w-0">
<span className="font-medium">{user.name || '—'}</span>
<span className="text-sm text-[var(--color-text-muted)] ml-2">{user.email}</span>
</div>
<div className="flex items-center gap-2">
<Badge tone={ROLE_TONES[rn] || 'neutral'}>{ROLE_LABELS[rn] || rn}</Badge>
<button
onClick={() => startEdit(user)}
className="rounded-lg border border-[var(--color-accent)] px-2.5 py-1 text-xs font-semibold text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition"
>Изменить</button>
{deleteConfirmId === user.id ? (
<div className="flex gap-1">
<button onClick={() => handleDeleteUser(user.id)} className="rounded-lg bg-[var(--color-danger)] px-2.5 py-1 text-xs font-semibold text-white transition">Да</button>
<button onClick={() => setDeleteConfirmId(null)} className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs text-[var(--color-text-muted)] transition">Нет</button>
</div>
) : (
<button
onClick={() => setDeleteConfirmId(user.id)}
className="rounded-lg border border-[var(--color-danger)] px-2.5 py-1 text-xs font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)] transition"
>Удалить</button>
)}
</div>
</div>
);
})}
</div>
</Panel>
);
}

View File

@ -18,6 +18,36 @@ const STATE_LABELS = {
agreed: "Доставка согласована", agreed: "Доставка согласована",
}; };
const splitOrderItem = (item) => {
if (!item) {
return null;
}
if (typeof item === "string") {
const [name, quantity] = item.split("|").map((part) => part.trim());
return {
name: name || item.trim(),
quantity: quantity || "",
};
}
if (typeof item === "object") {
const name = typeof item.name === "string" ? item.name.trim() : typeof item.label === "string" ? item.label.trim() : "";
const quantity = typeof item.quantity === "string"
? item.quantity.trim()
: typeof item.quantity === "number"
? String(item.quantity)
: "";
return {
name: name || "Позиция",
quantity,
};
}
return null;
};
export const DeliveryChoiceFlow = ({ export const DeliveryChoiceFlow = ({
invitation = {}, invitation = {},
selectedSlot = null, selectedSlot = null,
@ -26,6 +56,9 @@ export const DeliveryChoiceFlow = ({
const state = invitation.state || "awaiting_choice"; const state = invitation.state || "awaiting_choice";
const isActive = ACTIVE_STATES.has(state); const isActive = ACTIVE_STATES.has(state);
const invitationReference = getInvitationReferenceLabel(invitation); const invitationReference = getInvitationReferenceLabel(invitation);
const orderItems = (invitation.orderItems || invitation.items || [])
.map(splitOrderItem)
.filter(Boolean);
const slotSummary = selectedSlot ? formatDeliverySlotLabel(selectedSlot) : ""; const slotSummary = selectedSlot ? formatDeliverySlotLabel(selectedSlot) : "";
if (!isActive) { if (!isActive) {
@ -45,10 +78,27 @@ export const DeliveryChoiceFlow = ({
<Badge tone="warning">{STATE_LABELS[state]}</Badge> <Badge tone="warning">{STATE_LABELS[state]}</Badge>
</div> </div>
<p className="text-sm leading-6 text-[var(--color-text-muted)]"> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
{invitationReference}. Выберите удобную половину дня. {invitationReference}. Проверьте состав заказа и выберите удобную половину дня.
</p> </p>
</div> </div>
{orderItems.length ? (
<div className="space-y-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">Состав заказа</p>
<div className="space-y-2">
{orderItems.map((item) => (
<div
key={`${item.name}-${item.quantity || "item"}`}
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
>
<span className="leading-6">{item.name}</span>
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
</div>
))}
</div>
</div>
) : null}
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Button <Button
className="w-full sm:w-auto" className="w-full sm:w-auto"

View File

@ -2,7 +2,6 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DeliveryChoiceFlow } from "./DeliveryChoiceFlow"; import { DeliveryChoiceFlow } from "./DeliveryChoiceFlow";
import { OrderCompositionPanel } from "./OrderCompositionPanel";
describe("DeliveryChoiceFlow", () => { describe("DeliveryChoiceFlow", () => {
it("renders the active delivery choice with half-day actions", () => { it("renders the active delivery choice with half-day actions", () => {
@ -39,6 +38,32 @@ describe("DeliveryChoiceFlow", () => {
expect(markup).toContain("disabled"); expect(markup).toContain("disabled");
}); });
it("renders order items with quantities when they are provided", () => {
const markup = renderToStaticMarkup(
<DeliveryChoiceFlow
invitation={{
state: "awaiting_choice",
orderNumber: "CD-240031",
customerName: "Мария Волкова",
orderItems: [
{ name: "Кухонный гарнитур", quantity: "1 комплект" },
{ name: "Фурнитура Blum", quantity: "12 шт" },
{ name: "Монтажный комплект" },
],
availableSlots: ["Первая половина дня", "Вторая половина дня"],
}}
onConfirmChoice={() => {}}
/>,
);
expect(markup).toContain("Состав заказа");
expect(markup).toContain("Кухонный гарнитур");
expect(markup).toContain("1 комплект");
expect(markup).toContain("Фурнитура Blum");
expect(markup).toContain("12 шт");
expect(markup).toContain("Монтажный комплект");
});
it("renders a logistics notice when the order is transferred", () => { it("renders a logistics notice when the order is transferred", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<DeliveryChoiceFlow <DeliveryChoiceFlow
@ -88,31 +113,3 @@ describe("DeliveryChoiceFlow", () => {
expect(markup).not.toContain("Александр Савин"); expect(markup).not.toContain("Александр Савин");
}); });
}); });
describe("OrderCompositionPanel", () => {
it("renders with position count and collapsed state", () => {
const markup = renderToStaticMarkup(
<OrderCompositionPanel
invitation={{
orderNumber: "CD-240031",
orderItems: [
{ name: "Кухонный гарнитур", quantity: "1 комплект" },
{ name: "Фурнитура Blum", quantity: "12 шт" },
{ name: "Монтажный комплект" },
],
}}
/>,
);
expect(markup).toContain("Состав заказа");
expect(markup).toContain("3 поз.");
});
it("renders nothing when there are no order items", () => {
const markup = renderToStaticMarkup(
<OrderCompositionPanel invitation={{ orderNumber: "CD-240031" }} />,
);
expect(markup).toBe("");
});
});

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting"; import { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
const groupSlotsByDate = (slots) => { const groupSlotsByDate = (slots) => {
const groups = new Map(); const groups = new Map();
@ -36,19 +36,7 @@ const groupSlotsByDate = (slots) => {
.sort(([a], [b]) => a.localeCompare(b)); .sort(([a], [b]) => a.localeCompare(b));
}; };
const getDeliverySlotGroupHeading = (dateStr, referenceDate = new Date()) => { export { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
const relative = getDeliveryRelativeDayLabel(dateStr, referenceDate);
const formatted = formatDeliveryDate(dateStr);
if (relative) {
return `Доставка ${relative.charAt(0).toLowerCase()}${relative.slice(1)} · ${formatted}`;
}
return `Доставка ${formatted}`;
};
export { formatDeliveryDate, formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
export { getDeliverySlotGroupHeading };
export const DeliverySlotsPicker = ({ export const DeliverySlotsPicker = ({
slots, slots,
@ -72,7 +60,10 @@ export const DeliverySlotsPicker = ({
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open> <details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
<summary className="cursor-pointer list-none p-5 sm:p-6"> <summary className="cursor-pointer list-none p-5 sm:p-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h4 className="font-medium">{getDeliverySlotGroupHeading(date, referenceDate)}</h4> <div className="space-y-1">
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">Доставка на день</p>
<h4 className="font-medium">{formatDeliverySlotGroupLabel(date, referenceDate)}</h4>
</div>
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span> <span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span> <span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
</div> </div>

View File

@ -1,130 +0,0 @@
import React from "react";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { getInvitationReferenceLabel } from "./invitationReference";
const flattenOrderProducts = (rawItems) => {
if (!Array.isArray(rawItems) || rawItems.length === 0) return [];
const products = [];
for (const item of rawItems) {
if (!item || typeof item !== "object") continue;
const subItems = Array.isArray(item.items) ? item.items : [];
if (subItems.length > 0) {
const hasSubOrders = subItems.some(
(s) => typeof s === "object" && ("nom" in s || ("items" in s && Array.isArray(s.items))),
);
if (hasSubOrders) {
for (const sub of subItems) {
if (!sub || typeof sub !== "object") continue;
const productsList = Array.isArray(sub.items) ? sub.items : [];
for (const p of productsList) {
if (!p || typeof p !== "object") continue;
const pName = String(p.product_name || p.name || "").trim();
if (!pName) continue;
products.push({
name: pName,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
});
}
}
} else {
for (const p of subItems) {
if (!p || typeof p !== "object") continue;
const pName = String(p.product_name || p.name || "").trim();
if (!pName) continue;
products.push({
name: pName,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
});
}
}
continue;
}
const name = String(item.product_name || item.name || item.nom || "").trim();
if (!name) continue;
products.push({
name,
quantity: String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(),
unit: String(item.product_ed || item.unit || "").trim(),
});
}
return products;
};
const matchesStopWord = (name, stopWords) => {
if (!stopWords || !stopWords.length) return false;
const lower = name.toLowerCase();
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
};
export const OrderCompositionPanel = ({ invitation = {} }) => {
const stopWords = invitation.stopWords || [];
const rawItems = invitation.orderItems || invitation.items || [];
const allProducts = flattenOrderProducts(rawItems);
const products = stopWords.length
? allProducts.filter((p) => !matchesStopWord(p.name, stopWords))
: allProducts;
const filteredCount = allProducts.length - products.length;
const reference = getInvitationReferenceLabel(invitation);
const [isExpanded, setIsExpanded] = React.useState(false);
if (products.length === 0 && filteredCount === 0) return null;
return (
<Panel className="space-y-3 border shadow-soft p-5 sm:p-6">
<button
type="button"
className="flex w-full items-center justify-between text-left"
onClick={() => setIsExpanded(!isExpanded)}
>
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Состав заказа{reference && reference !== "Счет —" ? ` ${reference}` : ""}
</p>
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
{products.length} поз.
<svg
className="h-4 w-4 transition-transform"
style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{isExpanded && (
<div className="space-y-2">
{products.map((product, idx) => (
<div
key={`${product.name}-${idx}`}
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
>
<span className="leading-6">{product.name}</span>
{(product.quantity || product.unit) ? (
<Badge tone="neutral">{[product.quantity, product.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
))}
{products.length === 0 && filteredCount > 0 && (
<p className="text-sm text-[var(--color-text-muted)]">
Все позиции исключены из отображения.
</p>
)}
</div>
)}
</Panel>
);
};

View File

@ -5,9 +5,9 @@ export const KpiCard = ({ label, value, hint }) => {
return ( return (
<Panel className="p-5"> <Panel className="p-5">
<p className="text-sm text-[var(--color-text-muted)]">{label}</p> <p className="text-sm text-[var(--color-text-muted)]">{label}</p>
<div className="mt-3"> <div className="mt-4 flex items-end justify-between gap-4">
<span className="text-3xl font-semibold">{value}</span> <span className="text-3xl font-semibold">{value}</span>
{hint && <p className="mt-1 text-xs text-[var(--color-text-muted)]">{hint}</p>} <span className="text-xs text-[var(--color-text-muted)]">{hint}</span>
</div> </div>
</Panel> </Panel>
); );

View File

@ -1,42 +1,10 @@
import React, { useState } from "react"; import React from "react";
import { getAvailableTransitionsByRole, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow"; import { getAvailableTransitionsByRole, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../../services/driverDeliveries"; import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../../services/driverDeliveries";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
const PROBLEM_REASONS = [
{ value: "client_absent", label: "Клиент не принял", description: "Клиент отказался или не вышел на связь" },
{ value: "damage", label: "Повреждение заказа", description: "Товар повреждён при транспортировке" },
{ value: "wrong_address", label: "Неверный адрес", description: "Адрес доставки указан неверно" },
{ value: "other", label: "Другое", description: "Иная причина проблемы доставки" },
];
const ProblemReasonModal = ({ onSelect, onCancel }) => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
<Panel className="mx-4 w-full max-w-md space-y-4 p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold">Причина проблемы</h3>
<p className="text-sm text-[var(--color-text-muted)]">Укажите причину возникшей проблемы с доставкой.</p>
<div className="space-y-2">
{PROBLEM_REASONS.map((reason) => (
<button
key={reason.value}
type="button"
className="w-full rounded-[16px] border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]"
onClick={() => onSelect(reason.value, reason.label)}
>
<span className="font-medium">{reason.label}</span>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">{reason.description}</p>
</button>
))}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onCancel}>Отмена</Button>
</div>
</Panel>
</div>
);
const splitItem = (item) => { const splitItem = (item) => {
if (!item) { if (!item) {
return { name: "Позиция", quantity: "" }; return { name: "Позиция", quantity: "" };
@ -61,8 +29,6 @@ const splitItem = (item) => {
}; };
export const DriverDeliveryDetail = ({ order, onStatusChange }) => { export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
const [showProblemModal, setShowProblemModal] = useState(false);
if (!order) { if (!order) {
return null; return null;
} }
@ -73,42 +39,8 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
}); });
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : []; const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
const currentStatus = order.status;
const IN_TRANSIT_STATUSES = ["Загружен", "В пути"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let actionButtons = [];
if (currentStatus === "Назначен водитель") {
actionButtons = [
{ value: "Загружен", label: "Загружено" },
{ value: "Проблема доставки", label: "Проблема" },
];
} else if (isOnRoute) {
actionButtons = [
{ value: "Доставлен", label: "Доставлено" },
{ value: "Проблема доставки", label: "Проблема" },
];
} else if (currentStatus === "Доставлен" || currentStatus === "Проблема доставки" || currentStatus === "Закрыт" || currentStatus === "Отменён") {
actionButtons = [];
} else {
actionButtons = availableTransitions.map((status) => ({
value: status,
label: status === "Проблема доставки" ? "Проблема" : status,
}));
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{showProblemModal && (
<ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => {
setShowProblemModal(false);
onStatusChange?.("Проблема доставки", { reason: reasonValue, reasonLabel });
}}
onCancel={() => setShowProblemModal(false)}
/>
)}
<Panel className="space-y-5 p-6"> <Panel className="space-y-5 p-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
@ -178,28 +110,22 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
</div> </div>
</Panel> </Panel>
{actionButtons.length > 0 && ( {availableTransitions.length ? (
<Panel className="space-y-4 p-6"> <Panel className="space-y-4 p-6">
<h3 className="text-lg font-semibold">Быстрые действия</h3> <h3 className="text-lg font-semibold">Быстрые действия</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{actionButtons.map((btn) => ( {availableTransitions.map((status) => (
<Button <Button
key={btn.value} key={status}
variant={btn.value === "Проблема доставки" ? "ghost" : "secondary"} variant={status === "Проблема доставки" ? "ghost" : "secondary"}
onClick={() => { onClick={() => onStatusChange?.(status)}
if (btn.value === "Проблема доставки") {
setShowProblemModal(true);
return;
}
onStatusChange?.(btn.value);
}}
> >
{btn.label} {status}
</Button> </Button>
))} ))}
</div> </div>
</Panel> </Panel>
)} ) : null}
</div> </div>
); );
}; };

View File

@ -1,198 +1,59 @@
import { CRIMEAN_CITIES } from "../../constants/cities.js";
import React from "react"; import React from "react";
import { import {
filterOrderGroups,
getOrderGroupDeliveryHalfDay, getOrderGroupDeliveryHalfDay,
getOrderGroupDeliveryStatusLabel, getOrderGroupDeliveryStatusLabel,
getOrderGroupDeliveryStatusTone, getOrderGroupDeliveryStatusTone,
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
DRIVER_VISIBLE_DELIVERY_STATUSES, DRIVER_VISIBLE_DELIVERY_STATUSES,
isOrderGroupVisibleToDriver, isOrderGroupVisibleToDriver,
groupOrderGroupsByDate, groupOrderGroupsByDate,
parseGroupDate,
} from "../../services/orderGroupViews"; } from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Input } from "../UI/Input"; import { Input } from "../UI/Input";
import { Select } from "../UI/Select"; import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
const CHEVRON_DOWN = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6l4 4 4-4" />
</svg>
);
const CHEVRON_RIGHT = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4l4 4-4 4" />
</svg>
);
const extractCity = (address) => {
if (!address || typeof address !== "string") return null;
const trimmed = address.trim();
if (!trimmed) return null;
const cityMatch = trimmed.match(/(?:г\.\s+|гор\.\s+|пос\.\s+|с\.\s+|дер\.\s+|пгт\.\s+|город\s+|село\s+|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|\s|$)/i);
if (cityMatch) {
return cityMatch[1].trim();
}
for (const city of CRIMEAN_CITIES) {
if (trimmed.toLowerCase().includes(city.toLowerCase())) {
return city;
}
}
// Бахчисарайский р-н Бахчисарай
const district = trimmed.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
if (district) {
const base = district[1];
for (const city of CRIMEAN_CITIES) {
if (city.toLowerCase().startsWith(base.toLowerCase())) return city;
}
}
// no match null (caller falls back to Севастополь)
return null;
};
const normalizeCity = (address) => {
const city = extractCity(address);
return city || "Севастополь";
};
const DRIVER_DELIVERY_STATUS_OPTIONS = [ const DRIVER_DELIVERY_STATUS_OPTIONS = [
{ value: "all", label: "Все статусы" }, { value: "all", label: "Все статусы" },
...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({ ...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({
value: status, value: status,
label: status === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(status), label: getOrderGroupDeliveryStatusLabel(status),
})), })),
]; ];
const pluralGroups = (n) => { export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
if (n === 1) return "группа";
if (n >= 2 && n < 5) return "группы";
return "групп";
};
/** Count items by status, return array of {status, label, tone, count} */
const countByStatus = (items) => {
const map = new Map();
for (const item of items) {
const s = item.deliveryStatus || item.delivery_status || "unknown";
map.set(s, (map.get(s) || 0) + 1);
}
const result = [];
for (const [status, count] of map) {
result.push({
status,
label: status === "driver_assigned" ? "Назначено" : getOrderGroupDeliveryStatusLabel(status),
tone: getOrderGroupDeliveryStatusTone(status),
count,
});
}
// Sort: delivered last (green = done), others by severity
const order = ["problem", "cancelled", "on_route", "loaded", "driver_assigned", "paid_storage", "delivered"];
result.sort((a, b) => {
const ia = order.indexOf(a.status);
const ib = order.indexOf(b.status);
if (ia === -1 && ib === -1) return a.status.localeCompare(b.status);
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return result;
};
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
const [filters, setFilters] = React.useState({ const [filters, setFilters] = React.useState({
selectedDate: "", dateFrom: "",
dateTo: "",
deliveryHalfDay: "all",
deliveryStatus: "all", deliveryStatus: "all",
selectedCity: "",
}); });
const [collapsedDates, setCollapsedDates] = React.useState({});
const toggleDate = (date) => { const agreedOrderGroups = React.useMemo(
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] })); () => orderGroups.filter((group) => isOrderGroupVisibleToDriver(group)),
}; [orderGroups],
const driverOrderGroups = React.useMemo(
() => orderGroups.filter((group) => {
const isVisible = isOrderGroupVisibleToDriver(group);
const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id;
return isVisible && isAssignedToMe;
}),
[orderGroups, currentUser],
); );
const dateDeliveryMap = React.useMemo(() => { const filteredOrderGroups = React.useMemo(
const map = new Map(); () =>
driverOrderGroups.forEach((group) => { filterOrderGroups(agreedOrderGroups, {
const date = group.deliveryDate; dateFrom: filters.dateFrom,
if (date) { dateTo: filters.dateTo,
map.set(date, (map.get(date) || 0) + 1); deliveryHalfDay: filters.deliveryHalfDay,
} deliveryStatus: filters.deliveryStatus,
}); }),
return map; [agreedOrderGroups, filters.dateFrom, filters.dateTo, filters.deliveryHalfDay, filters.deliveryStatus],
}, [driverOrderGroups]); );
const sortedDeliveryDates = React.useMemo(() => {
return Array.from(dateDeliveryMap.keys()).sort();
}, [dateDeliveryMap]);
const cityDeliveryMap = React.useMemo(() => {
const map = new Map();
driverOrderGroups.forEach((group) => {
const city = normalizeCity(group.deliveryAddress || group.delivery_address);
map.set(city, (map.get(city) || 0) + 1);
});
return map;
}, [driverOrderGroups]);
const sortedCities = React.useMemo(() => {
return Array.from(cityDeliveryMap.keys()).sort((a, b) => {
if (a === "Севастополь") return -1;
if (b === "Севастополь") return 1;
return a.localeCompare(b, "ru");
});
}, [cityDeliveryMap]);
const filteredOrderGroups = React.useMemo(() => {
let result = [...driverOrderGroups];
if (filters.selectedDate) {
result = result.filter((group) => group.deliveryDate === filters.selectedDate);
}
if (filters.deliveryStatus !== "all") {
result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus);
}
if (filters.selectedCity) {
result = result.filter((group) => {
const city = normalizeCity(group.deliveryAddress || group.delivery_address);
return city === filters.selectedCity;
});
}
return result;
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]);
const groupedOrderGroups = React.useMemo( const groupedOrderGroups = React.useMemo(
() => groupOrderGroupsByDate(filteredOrderGroups), () => groupOrderGroupsByDate(filteredOrderGroups),
[filteredOrderGroups], [filteredOrderGroups],
); );
const deliveryCountLabel = `${filteredOrderGroups.length} ${ const deliveryCountLabel = `${filteredOrderGroups.length} ${
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок" filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
}`; }`;
const isDateSelected = (date) => filters.selectedDate === date;
// Compute per-date status summary for collapsed badges
const dateStatusSummary = React.useMemo(() => {
const summary = {};
for (const dg of groupedOrderGroups) {
summary[dg.date] = countByStatus(dg.items);
}
return summary;
}, [groupedOrderGroups]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Panel className="space-y-3 p-5"> <Panel className="space-y-3 p-5">
@ -204,22 +65,49 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
<Badge tone="neutral">{deliveryCountLabel}</Badge> <Badge tone="neutral">{deliveryCountLabel}</Badge>
</div> </div>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <p className="mt-1 text-sm text-[var(--color-text-muted)]">
Показываем только назначенные вам группы доставки. Выберите дату и город. Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня.
</p> </p>
</div> </div>
</div> </div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"> <div className="grid gap-3 md:grid-cols-[repeat(4,minmax(0,1fr))]">
<label className="flex min-w-0 flex-col gap-2"> <label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Дата Дата от
</span> </span>
<Input <Input
type="date" type="date"
value={filters.selectedDate} value={filters.dateFrom}
onChange={(event) => setFilters((current) => ({ ...current, selectedDate: event.target.value }))} onChange={(event) => setFilters((current) => ({ ...current, dateFrom: event.target.value }))}
/> />
</label> </label>
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Дата до
</span>
<Input
type="date"
value={filters.dateTo}
onChange={(event) => setFilters((current) => ({ ...current, dateTo: event.target.value }))}
/>
</label>
<label className="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
value={filters.deliveryHalfDay}
onChange={(event) =>
setFilters((current) => ({ ...current, deliveryHalfDay: event.target.value }))
}
>
{ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</label>
<label className="flex min-w-0 flex-col gap-2"> <label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Статус Статус
@ -238,156 +126,30 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
</Select> </Select>
</label> </label>
</div> </div>
{/* Date pills */}
{sortedDeliveryDates.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
className={[
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
!filters.selectedDate
? "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)]",
].join(" ")}
>
Все даты
</button>
{sortedDeliveryDates.map((date) => {
const count = dateDeliveryMap.get(date) || 0;
const selected = isDateSelected(date);
return (
<button
key={date}
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
className={[
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
selected
? "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)]",
].join(" ")}
>
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</span>
{count > 0 && (
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
{count}
</span>
)}
</button>
);
})}
</div>
)}
{/* City pills */}
{sortedCities.length > 1 && (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
className={[
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
!filters.selectedCity
? "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)]",
].join(" ")}
>
Все города
</button>
{sortedCities.map((city) => {
const count = cityDeliveryMap.get(city) || 0;
const selected = filters.selectedCity === city;
return (
<button
key={city}
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
className={[
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
selected
? "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)]",
].join(" ")}
>
<span>{city}</span>
{count > 0 && (
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
{count}
</span>
)}
</button>
);
})}
</div>
)}
</div> </div>
</Panel> </Panel>
{groupedOrderGroups.length ? ( {groupedOrderGroups.length ? (
groupedOrderGroups.map((group) => { groupedOrderGroups.map((group) => (
const isCollapsed = collapsedDates[group.date];
const statusCounts = dateStatusSummary[group.date] || [];
// Group items by delivery status within each date
const statusBuckets = new Map();
for (const item of group.items) {
const s = item.deliveryStatus || item.delivery_status || "unknown";
const label = s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s);
const tone = getOrderGroupDeliveryStatusTone(s);
if (!statusBuckets.has(s)) {
statusBuckets.set(s, { label, tone, items: [] });
}
statusBuckets.get(s).items.push(item);
}
const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"];
const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => {
const ia = statusOrder.indexOf(a);
const ib = statusOrder.indexOf(b);
if (ia === -1 && ib === -1) return a.localeCompare(b);
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return (
<Panel key={group.date} className="space-y-4 p-5"> <Panel key={group.date} className="space-y-4 p-5">
<button <div className="flex flex-wrap items-center justify-between gap-3">
type="button" <div>
className="flex w-full items-center gap-3 text-left"
onClick={() => toggleDate(group.date)}
>
<span className="shrink-0 text-[var(--color-text-muted)] transition-transform">
{isCollapsed ? CHEVRON_RIGHT : CHEVRON_DOWN}
</span>
<div className="min-w-0 flex-1">
<h4 className="text-lg font-semibold capitalize"> <h4 className="text-lg font-semibold capitalize">
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", { {new Date(`${group.date}T12:00:00`).toLocaleDateString("ru-RU", {
day: "numeric", day: "numeric",
month: "long", month: "long",
weekday: "long", weekday: "long",
}) || "Без даты"} })}
</h4> </h4>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
</p>
</div> </div>
<div className="flex shrink-0 flex-wrap items-center gap-1.5"> <Badge tone="neutral">{group.date}</Badge>
{statusCounts.map(({ status, label, tone, count }) => (
<Badge key={status} tone={tone}>{count} {label}</Badge>
))}
</div> </div>
</button>
{!isCollapsed && (
<div className="space-y-4 pt-1">
{sortedBuckets.map(([statusValue, { label, tone, items }]) => (
<div key={statusValue} className="space-y-2">
<div className="flex items-center gap-2">
<Badge tone={tone}>{label}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {pluralGroups(items.length)}</span>
</div>
<div className="grid gap-3"> <div className="grid gap-3">
{items.map((item) => ( {group.items.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@ -404,21 +166,23 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""} {getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
</div> </div>
</div> </div>
<Badge tone={getOrderGroupDeliveryStatusTone(item.deliveryStatus || item.delivery_status)}>
{getOrderGroupDeliveryStatusLabel(item.deliveryStatus || item.delivery_status)}
</Badge>
</div> </div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]"> <div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
{item.deliveryAddress || item.delivery_address || "Адрес не указан"} <div>{item.orderNumbers?.[0] || "Номера не указаны"}</div>
<div>
{item.readyCount || 0}/{item.ordersCount || 0} готово
</div>
<div>{item.smsSentAt ? "SMS отправлено" : "SMS не отправлено"}</div>
</div> </div>
</button> </button>
))} ))}
</div> </div>
</div>
))}
</div>
)}
</Panel> </Panel>
); ))
})
) : ( ) : (
<Panel className="p-6"> <Panel className="p-6">
<h4 className="text-lg font-semibold">Доставки не найдены</h4> <h4 className="text-lg font-semibold">Доставки не найдены</h4>

View File

@ -1,371 +0,0 @@
import React from "react";
import {
getOrderGroupDeliveryHalfDay,
getOrderGroupDeliveryStatusLabel,
getOrderGroupDeliveryStatusTone,
DRIVER_VISIBLE_DELIVERY_STATUSES,
isOrderGroupVisibleToDriver,
groupOrderGroupsByDate,
parseGroupDate,
} from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge";
import { Input } from "../UI/Input";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel";
const extractCity = (address) => {
if (!address || typeof address !== "string") return null;
const trimmed = address.trim();
if (!trimmed) return null;
// Patterns: "г.Ялта", "г. Ялта", "г Ялта", "г Ялта ", "Ялта,", "г.Севастополь", etc.
const cityMatch = trimmed.match(/(?:г\.?\s*|г\s+)([А-ЯЁA-Z][а-яёa-zA-Z\s\-]+?)(?:\s*[,;.]|$)/i);
if (cityMatch) {
return cityMatch[1].trim();
}
// Try common city names directly in the address
const knownCities = [
"Севастополь", "Ялта", "Симферополь", "Феодосия", "Евпатория",
"Керчь", "Алушта", "Бахчисарай", "Судак", "Инкерман",
"Джанкой", "Красногвардейское", "Раздольное", "Черноморское",
];
for (const city of knownCities) {
if (trimmed.toLowerCase().includes(city.toLowerCase())) {
return city;
}
}
// Fallback: first comma-separated segment if it looks like a city
const firstSegment = trimmed.split(/[,;]/)[0].trim();
if (firstSegment.length > 2 && firstSegment.length < 30 && !/^\d/.test(firstSegment)) {
return firstSegment;
}
return null;
};
const normalizeCity = (address) => {
const city = extractCity(address);
return city || "Севастополь";
};
const DRIVER_DELIVERY_STATUS_OPTIONS = [
{ value: "all", label: "Все статусы" },
...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({
value: status,
label: status === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(status),
})),
];
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
const [filters, setFilters] = React.useState({
selectedDate: "",
deliveryStatus: "all",
selectedCity: "",
});
const driverOrderGroups = React.useMemo(
() => orderGroups.filter((group) => {
const isVisible = isOrderGroupVisibleToDriver(group);
const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id;
return isVisible && isAssignedToMe;
}),
[orderGroups, currentUser],
);
// Build map of date -> count
const dateDeliveryMap = React.useMemo(() => {
const map = new Map();
driverOrderGroups.forEach((group) => {
const date = group.deliveryDate;
if (date) {
map.set(date, (map.get(date) || 0) + 1);
}
});
return map;
}, [driverOrderGroups]);
const sortedDeliveryDates = React.useMemo(() => {
return Array.from(dateDeliveryMap.keys()).sort();
}, [dateDeliveryMap]);
// Build map of city -> count
const cityDeliveryMap = React.useMemo(() => {
const map = new Map();
driverOrderGroups.forEach((group) => {
const city = normalizeCity(group.deliveryAddress || group.delivery_address);
map.set(city, (map.get(city) || 0) + 1);
});
return map;
}, [driverOrderGroups]);
const sortedCities = React.useMemo(() => {
return Array.from(cityDeliveryMap.keys()).sort((a, b) => {
// Севастополь first, then alphabetical
if (a === "Севастополь") return -1;
if (b === "Севастополь") return 1;
return a.localeCompare(b, "ru");
});
}, [cityDeliveryMap]);
const filteredOrderGroups = React.useMemo(() => {
let result = [...driverOrderGroups];
if (filters.selectedDate) {
result = result.filter((group) => group.deliveryDate === filters.selectedDate);
}
if (filters.deliveryStatus !== "all") {
result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus);
}
if (filters.selectedCity) {
result = result.filter((group) => {
const city = normalizeCity(group.deliveryAddress || group.delivery_address);
return city === filters.selectedCity;
});
}
return result;
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus, filters.selectedCity]);
const groupedOrderGroups = React.useMemo(
() => groupOrderGroupsByDate(filteredOrderGroups),
[filteredOrderGroups],
);
const deliveryCountLabel = `${filteredOrderGroups.length} ${
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
}`;
const isDateSelected = (date) => filters.selectedDate === date;
return (
<div className="space-y-4">
<Panel className="space-y-3 p-5">
<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>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label className="flex min-w-0 flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Дата
</span>
<Input
type="date"
value={filters.selectedDate}
onChange={(event) => setFilters((current) => ({ ...current, selectedDate: event.target.value }))}
/>
</label>
<label className="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
value={filters.deliveryStatus}
onChange={(event) =>
setFilters((current) => ({ ...current, deliveryStatus: event.target.value }))
}
>
{DRIVER_DELIVERY_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</label>
</div>
{/* Date pills */}
{sortedDeliveryDates.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
className={[
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
!filters.selectedDate
? "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)]",
].join(" ")}
>
Все даты
</button>
{sortedDeliveryDates.map((date) => {
const count = dateDeliveryMap.get(date) || 0;
const selected = isDateSelected(date);
return (
<button
key={date}
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
className={[
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
selected
? "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)]",
].join(" ")}
>
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</span>
{count > 0 && (
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
{count}
</span>
)}
</button>
);
})}
</div>
)}
{/* City pills */}
{sortedCities.length > 1 && (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
className={[
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
!filters.selectedCity
? "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)]",
].join(" ")}
>
Все города
</button>
{sortedCities.map((city) => {
const count = cityDeliveryMap.get(city) || 0;
const selected = filters.selectedCity === city;
return (
<button
key={city}
type="button"
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
className={[
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
selected
? "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)]",
].join(" ")}
>
<span>{city}</span>
{count > 0 && (
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
{count}
</span>
)}
</button>
);
})}
</div>
)}
</div>
</Panel>
{groupedOrderGroups.length ? (
groupedOrderGroups.map((group) => {
// Group items by delivery status within each date
const statusBuckets = new Map();
for (const item of group.items) {
const s = item.deliveryStatus || item.delivery_status || "unknown";
const label = s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s);
const tone = getOrderGroupDeliveryStatusTone(s);
if (!statusBuckets.has(s)) {
statusBuckets.set(s, { label, tone, items: [] });
}
statusBuckets.get(s).items.push(item);
}
// Sort status buckets in driver-relevant order
const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "problem"];
const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => {
const ia = statusOrder.indexOf(a);
const ib = statusOrder.indexOf(b);
if (ia === -1 && ib === -1) return a.localeCompare(b);
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return (
<Panel key={group.date} className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 className="text-lg font-semibold capitalize">
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
weekday: "long",
}) || "Без даты"}
</h4>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
</p>
</div>
<Badge tone="neutral">
{(() => {
const d = parseGroupDate(group.date);
if (!d) return group.date || "—";
const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const year = d.getFullYear();
return `${day}.${month}.${year}`;
})()}
</Badge>
</div>
{sortedBuckets.map(([statusValue, { label, tone, items }]) => (
<div key={statusValue} className="space-y-2">
<div className="flex items-center gap-2">
<Badge tone={tone}>{label}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{items.length} {items.length === 1 ? "группа" : "группы"}</span>
</div>
<div className="grid gap-3">
{items.map((item) => (
<button
key={item.id}
type="button"
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
onClick={() => onOpenOrder?.(item.id)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-medium text-[var(--color-text)]">
{item.displayTitle || item.customerName || item.groupKey}
</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
{item.customerDate} · {item.customerPhone}
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
</div>
</div>
</div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
</div>
</button>
))}
</div>
</div>
))}
</Panel>
);
})
) : (
<Panel className="p-6">
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
Сейчас у вас нет назначенных групп доставки.
</p>
</Panel>
)}
</div>
);
};

View File

@ -18,7 +18,6 @@ const orderGroups = [
notReadyCount: 0, notReadyCount: 0,
status: "ready_for_notification", status: "ready_for_notification",
deliveryStatus: "agreed", deliveryStatus: "agreed",
assignedDriverId: "driver-1",
deliveryHalfDay: "Первая половина дня", deliveryHalfDay: "Первая половина дня",
smsSentAt: null, smsSentAt: null,
updatedAt: "2026-04-16T12:00:00Z", updatedAt: "2026-04-16T12:00:00Z",
@ -48,7 +47,6 @@ describe("DriverDeliveryPlanner", () => {
<DriverDeliveryPlanner <DriverDeliveryPlanner
orderGroups={orderGroups} orderGroups={orderGroups}
onOpenOrder={() => {}} onOpenOrder={() => {}}
currentUser={{ id: "driver-1" }}
/>, />,
); );
@ -57,7 +55,8 @@ describe("DriverDeliveryPlanner", () => {
expect(markup).toContain("Мария Волкова"); expect(markup).toContain("Мария Волкова");
expect(markup).toContain("CD-240031"); expect(markup).toContain("CD-240031");
expect(markup).not.toContain("Не показывать"); expect(markup).not.toContain("Не показывать");
expect(markup).toContain("Дата"); expect(markup).toContain("Дата от");
expect(markup).toContain("Время суток");
expect(markup).toContain("Статус"); expect(markup).toContain("Статус");
expect(markup).toContain("Согласовано"); expect(markup).toContain("Согласовано");
expect(markup).not.toContain("Канбан"); expect(markup).not.toContain("Канбан");

View File

@ -1,246 +0,0 @@
import React from "react";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
const parseOrderItems = (order) => {
if (!order) return [];
const sourceOrders = order.sourceOrders || order.source_orders;
if (Array.isArray(sourceOrders) && sourceOrders.length > 0) {
const products = [];
for (const src of sourceOrders) {
if (!src || typeof src !== "object") continue;
const orderList = Array.isArray(src.orderList) ? src.orderList : Array.isArray(src.items) ? src.items : [];
for (const sub of orderList) {
if (!sub || typeof sub !== "object") continue;
const subItems = Array.isArray(sub.items) ? sub.items : [];
const hasProducts = subItems.some(
(p) => typeof p === "object" && (p.product_name || p.name)
);
if (hasProducts) {
for (const p of subItems) {
if (!p || typeof p !== "object") continue;
const name = String(p.product_name || p.name || "").trim();
if (!name) continue;
products.push({
id: `${src.nom || ""}-${sub.nom || ""}-${name}`,
name,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
});
}
} else if (sub.nom || sub.name) {
products.push({
id: `${src.nom || ""}-${sub.nom || sub.name || ""}`,
name: String(sub.nom || sub.name || "").trim(),
quantity: "",
unit: "",
});
}
}
if (orderList.length === 0) {
const directItems = Array.isArray(src.items) ? src.items : [];
const hasDirect = directItems.some(
(p) => typeof p === "object" && (p.product_name || p.name) && !p.nom
);
if (hasDirect) {
for (const p of directItems) {
if (!p || typeof p !== "object") continue;
const name = String(p.product_name || p.name || "").trim();
if (!name || ("nom" in p && "items" in p)) continue;
products.push({
id: `${src.nom || ""}-${name}`,
name,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
});
}
}
}
}
if (products.length > 0) return products;
}
const orderList = order.orderList || order.order_list;
if (Array.isArray(orderList)) {
const products = [];
for (const sub of orderList) {
if (!sub || typeof sub !== "object") continue;
const items = Array.isArray(sub.items) ? sub.items : [];
for (const p of items) {
if (!p || typeof p !== "object") continue;
const name = String(p.product_name || p.name || "").trim();
if (!name) continue;
products.push({
id: `${sub.nom || sub.name || ""}-${name}`,
name,
quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(),
unit: String(p.product_ed || p.unit || "").trim(),
});
}
}
if (products.length > 0) return products;
}
return [];
};
export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
const items = React.useMemo(() => parseOrderItems(order), [order]);
const [shippedItems, setShippedItems] = React.useState(new Set());
const [comments, setComments] = React.useState({});
const [commentInput, setCommentInput] = React.useState("");
const toggleItem = (itemId) => {
setShippedItems((prev) => {
const next = new Set(prev);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
};
const shipAll = () => {
setShippedItems(new Set(items.map((i) => i.id)));
setComments({});
};
const unshipAll = () => {
setShippedItems(new Set());
setComments({});
};
const shippedCount = items.filter((i) => shippedItems.has(i.id)).length;
const unshippedCount = items.length - shippedCount;
const allShipped = items.length > 0 && shippedCount === items.length;
const unshippedWithComment = items.filter(
(i) => !shippedItems.has(i.id) && comments[i.id]?.trim(),
).length;
const unshippedWithoutComment = unshippedCount - unshippedWithComment;
// Notify parent of shipment state
React.useEffect(() => {
if (onShipmentChange) {
onShipmentChange({
total: items.length,
shipped: shippedCount,
unshipped: unshippedCount,
unshippedWithoutComment,
allShipped,
canMarkDelivered: allShipped || unshippedWithoutComment === 0,
shipmentData: items.map((item) => ({
id: item.id,
name: item.name,
quantity: item.quantity,
unit: item.unit,
shipped: shippedItems.has(item.id),
comment: shippedItems.has(item.id) ? "" : (comments[item.id] || ""),
})),
});
}
}, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]);
if (items.length === 0) {
return (
<Panel className="space-y-3 p-5">
<strong>Состав заказа</strong>
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
</Panel>
);
}
return (
<Panel className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<strong>Отгрузка</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены.
</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Badge tone={allShipped ? "accent" : "neutral"}>
{shippedCount}/{items.length} отгружено
</Badge>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
Отгрузить всё
</Button>
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0}>
Сбросить
</Button>
</div>
<div className="space-y-2">
{items.map((item) => {
const isShipped = shippedItems.has(item.id);
const hasComment = !isShipped && comments[item.id]?.trim();
return (
<div
key={item.id}
className={[
"rounded-[18px] border px-4 py-3 text-sm transition",
isShipped
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: hasComment
? "border-[var(--color-warning)] bg-[var(--color-warning-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
].join(" ")}
>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isShipped}
onChange={() => toggleItem(item.id)}
className="mt-0.5 h-4 w-4 flex-shrink-0 accent-[var(--color-accent)]"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className={isShipped ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}>
{item.name}
</span>
{(item.quantity || item.unit) ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{!isShipped && (
<input
type="text"
placeholder="Причина неотгрузки (дефект, нет в наличии...)"
value={comments[item.id] || ""}
onChange={(e) =>
setComments((prev) => ({ ...prev, [item.id]: e.target.value }))
}
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
)}
</div>
</label>
</div>
);
})}
</div>
{unshippedCount > 0 && (
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
<p className="font-medium text-[var(--color-text)]">
Не отгружено: {unshippedCount} {unshippedCount === 1 ? "позиция" : unshippedCount < 5 ? "позиции" : "позиций"}
</p>
{unshippedWithoutComment > 0 && (
<p className="mt-1 text-[var(--color-text-muted)]">
Укажите причину для каждой неотгруженной позиции, чтобы завершить доставку.
</p>
)}
</div>
)}
</Panel>
);
};

View File

@ -1,77 +1,57 @@
import React from "react"; import React from "react";
import { import {
buildOrderGroupBuckets,
filterOrderGroups, filterOrderGroups,
getOrderGroupDisplayStatusLabel, getOrderGroupDisplayStatusLabel,
getOrderGroupDisplayStatusValue,
getOrderGroupStatusTone, getOrderGroupStatusTone,
ORDER_GROUP_BUCKET_LABELS,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS, ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
} from "../../services/orderGroupViews"; } from "../../services/orderGroupViews";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { OrderFilters } from "../orders/OrderFilters"; import { OrderFilters } from "../orders/OrderFilters";
import { formatDateTime } from "../../utils/formatters";
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS }) => { const BUCKET_ICONS = {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" }); ready_to_launch: "\u2713",
const [collapsedSections, setCollapsedSections] = React.useState(new Set()); sms_sent: "\u2709",
manual_work: "\u26A0",
};
const cities = React.useMemo(() => { const renderOrderNumbers = (group) => {
const set = new Set(); if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
for (const g of orderGroups) { return <span>Номера не указаны</span>;
if (g.city) set.add(g.city);
} }
return [...set].sort();
}, [orderGroups]); return (
<div className="flex flex-wrap gap-2">
{group.orderNumbers.map((number) => (
<span
key={number}
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
>
{number}
</span>
))}
</div>
);
};
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all" });
const filteredGroups = React.useMemo( const filteredGroups = React.useMemo(
() => filterOrderGroups(orderGroups, filters), () => filterOrderGroups(orderGroups, filters),
[filters, orderGroups], [filters, orderGroups],
); );
const deliveryGroupBuckets = React.useMemo(
const statusGroups = React.useMemo(() => { () => buildOrderGroupBuckets(filteredGroups),
const map = new Map(); [filteredGroups],
for (const group of filteredGroups) {
const statusValue = getOrderGroupDisplayStatusValue(group);
if (!map.has(statusValue)) {
const label = getOrderGroupDisplayStatusLabel(group);
map.set(statusValue, { label, groups: [] });
}
map.get(statusValue).groups.push(group);
}
return map;
}, [filteredGroups]);
const FUNNEL_ORDER = [
"status:ready_for_notification",
"delivery:pending_confirmation",
"status:manual_required",
"status:first_sms_sent",
"status:second_sms_sent",
"delivery:agreed",
"delivery:driver_assigned",
"delivery:loaded",
"delivery:on_route",
"delivery:delivered",
"delivery:paid_storage",
"delivery:problem",
"delivery:cancelled",
];
const totalGroups = filteredGroups.length;
const TableHeader = () => (
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
<tr>
<th className="px-4 py-3 font-medium">Клиент</th>
<th className="px-4 py-3 font-medium hidden sm:table-cell">Город</th>
<th className="px-4 py-3 font-medium hidden md:table-cell">Дата доставки</th>
<th className="px-4 py-3 font-medium hidden lg:table-cell">Водитель</th>
<th className="px-4 py-3 font-medium">Статус</th>
<th className="px-4 py-3 font-medium hidden md:table-cell">Обновлён</th>
</tr>
</thead>
); );
const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS);
const buckets = deliveryGroupBuckets || {};
const totalGroups = filteredGroups.length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
@ -85,101 +65,70 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
<OrderFilters <OrderFilters
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
statusOptions={statusOptions} statusOptions={ORDER_GROUP_DISPLAY_STATUS_OPTIONS}
cities={cities}
/> />
</Panel> </Panel>
{!totalGroups ? ( {!totalGroups ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]"> <Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
По этому поиску ничего не найдено. По этому поиску ничего не найдено.
</div> </Panel>
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-6 xl:grid-cols-2">
{Array.from(statusGroups.entries()).sort(([a], [b]) => { {bucketKeys.map((bucketKey) => {
const idxA = FUNNEL_ORDER.indexOf(a); const groups = buckets[bucketKey] || [];
const idxB = FUNNEL_ORDER.indexOf(b); const label = ORDER_GROUP_BUCKET_LABELS[bucketKey];
if (idxA === -1 && idxB === -1) return a.localeCompare(b); const icon = BUCKET_ICONS[bucketKey];
if (idxA === -1) return 1;
if (idxB === -1) return -1; if (!groups.length) {
return idxA - idxB; return (
}).map(([statusValue, { label, groups }]) => { <Panel key={bucketKey} className="p-5 opacity-50">
const isCollapsed = collapsedSections.has(statusValue); <div className="flex items-center gap-2">
<span className="text-lg">{icon}</span>
<h3 className="font-semibold">{label}</h3>
</div>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">Нет групп</p>
</Panel>
);
}
return ( return (
<Panel key={statusValue} className="overflow-hidden p-0"> <div key={bucketKey} className="space-y-3">
<button
type="button"
className="flex w-full items-center justify-between px-5 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
onClick={() => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(statusValue)) {
next.delete(statusValue);
} else {
next.add(statusValue);
}
return next;
});
}}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg">{icon}</span>
<h3 className="font-semibold">{label}</h3> <h3 className="font-semibold">{label}</h3>
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge> <Badge tone={bucketKey === "sms_sent" ? "accent" : "neutral"}>{groups.length}</Badge>
</div> </div>
<svg
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{!isCollapsed && (
<div className="overflow-x-auto">
<table className="min-w-full border-collapse border-t border-[var(--color-border)]">
<TableHeader />
<tbody>
{groups.map((group) => ( {groups.map((group) => (
<tr <button
key={group.id} key={group.id}
className="cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]" className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 !text-left !text-[var(--color-text)] transition hover:bg-[var(--color-accent-soft)] sm:p-5"
onClick={() => { if (onSelectSet) onSelectSet(group.id); }} onClick={() => {
> if (onSelectSet) {
<td className="px-4 py-2.5"> onSelectSet(group);
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="text-xs text-[var(--color-text-muted)] sm:hidden">{group.customerPhone || "—"}</div>
<div className="text-xs text-[var(--color-text-muted)] md:hidden">{group.deliveryDate || "—"}</div>
</td>
<td className="px-4 py-2.5 text-sm hidden sm:table-cell">
{group.city || group.customerAddress || "—"}
</td>
<td className="px-4 py-2.5 text-sm hidden md:table-cell">
{group.deliveryDate
? <span>{group.deliveryDate}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
: <span className="text-[var(--color-text-muted)]"></span>
} }
</td> }}
<td className="px-4 py-2.5 text-sm hidden lg:table-cell"> type="button"
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>} >
</td> <div className="space-y-2">
<td className="px-4 py-2.5"> <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge> <div className="break-words text-base font-semibold leading-tight !text-[var(--color-text)] sm:text-lg">
</td> {group.displayTitle || group.customerName || group.groupKey}
<td className="px-4 py-2.5 text-sm text-[var(--color-text-muted)] hidden md:table-cell"> </div>
{formatDateTime(group.updatedAt)} <Badge className="self-start" tone={getOrderGroupStatusTone(group)}>
</td> {getOrderGroupDisplayStatusLabel(group)}
</tr> </Badge>
))} </div>
</tbody> <div className="text-sm leading-6 text-[var(--color-text-muted)]">
</table> {group.customerDate || "—"} · {group.customerPhone || "—"} · {group.ordersCount || 0}{" "}
{group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}
</div>
<div>{renderOrderNumbers(group)}</div>
</div>
</button>
))}
</div> </div>
)}
</Panel>
); );
})} })}
</div> </div>

View File

@ -1,140 +0,0 @@
import React from "react";
import { Panel } from "../UI/Panel";
import { Button } from "../UI/Button";
import { Bell, Check, CheckCheck, Settings, X } from "../UI/Icons";
const TYPE_ICONS = {
driver_assigned: "🚚",
driver_unassigned: "📤",
order_status_change: "📦",
delivery_problem: "⚠️",
};
const TYPE_LABELS = {
driver_assigned: "Назначение водителя",
driver_unassigned: "Снятие водителя",
order_status_change: "Изменение статуса",
delivery_problem: "Проблема доставки",
};
function formatTimeAgo(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "сейчас";
if (diffMin < 60) return `${diffMin} мин`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} ч`;
const diffD = Math.floor(diffH / 24);
return `${diffD} д`;
}
export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onMarkAllAsRead, onOpenSettings }) {
const [isOpen, setIsOpen] = React.useState(false);
const bellRef = React.useRef(null);
React.useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (bellRef.current && !bellRef.current.contains(e.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
return (
<div className="relative" ref={bellRef}>
<button
className="relative flex h-9 w-9 items-center justify-center rounded-full transition hover:bg-[var(--color-surface-strong)]"
onClick={() => setIsOpen(!isOpen)}
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount})` : ""}`}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 sm:w-96">
<Panel className="max-h-[480px] overflow-hidden p-0 shadow-xl">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<h3 className="text-sm font-semibold">Уведомления</h3>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
className="rounded p-1 text-xs text-[var(--color-accent)] hover:bg-[var(--color-surface-strong)]"
onClick={onMarkAllAsRead}
title="Прочитать все"
>
<CheckCheck className="h-4 w-4" />
</button>
)}
{onOpenSettings && (
<button
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={() => { onOpenSettings(); setIsOpen(false); }}
title="Настройки"
>
<Settings className="h-4 w-4" />
</button>
)}
<button
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={() => setIsOpen(false)}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: "400px" }}>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
Нет уведомлений
</div>
) : (
notifications.map((notif) => (
<div
key={notif.id}
className={`flex gap-3 border-b border-[var(--color-border)] px-4 py-3 transition hover:bg-[var(--color-surface-strong)] ${
!notif.read ? "bg-[var(--color-accent-soft)]/30" : ""
}`}
onClick={() => !notif.read && onMarkAsRead(notif.id)}
>
<span className="mt-0.5 text-base">
{TYPE_ICONS[notif.type] || "📦"}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className={`text-sm ${!notif.read ? "font-semibold" : "font-normal"}`}>
{notif.title}
</p>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{formatTimeAgo(notif.created_at)}
</span>
</div>
{notif.body && (
<p className="mt-0.5 text-xs text-[var(--color-text-muted)] line-clamp-2">
{notif.body}
</p>
)}
</div>
{!notif.read && (
<div className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--color-accent)]" />
)}
</div>
))
)}
</div>
</Panel>
</div>
)}
</div>
);
}

View File

@ -1,104 +0,0 @@
import React from "react";
import { useNotificationPreferences } from "../../hooks/useNotifications";
import { usePushNotifications } from "../../hooks/usePushNotifications";
import { Panel } from "../UI/Panel";
import { Bell, Settings } from "../UI/Icons";
const ALL_NOTIF_TYPES = [
{ key: "order_status_change", label: "Изменение статуса", description: "Статус заказа или доставки изменился", roles: ["manager", "logistician", "driver", "admin", "mega_admin"] },
{ key: "driver_assigned", label: "Назначение на заказ", description: "Вам назначили заказ или доставку", roles: ["driver"] },
{ key: "driver_unassigned", label: "Снятие с заказа", description: "Вас сняли с заказа или доставки", roles: ["driver"] },
{ key: "delivery_problem", label: "Проблемы и отмены", description: "Отмена, проблема, невозможность дозвониться", roles: ["manager", "logistician", "admin", "mega_admin"] },
{ key: "new_order", label: "Новый заказ", description: "Создан новый заказ в системе", roles: ["manager", "logistician", "admin", "mega_admin"] },
{ key: "group_status_change", label: "Изменение группы доставки", description: "Статус группы доставки обновлён", roles: ["logistician", "manager", "admin", "mega_admin"] },
];
export function NotificationSettings({ userId, userRole, onBack }) {
const { prefs, isLoading: prefsLoading, updatePref } = useNotificationPreferences(userId);
const { isSupported, isSubscribed, isLoading: pushLoading, subscribe, unsubscribe } = usePushNotifications(userId);
const role = userRole || "manager";
const visibleTypes = ALL_NOTIF_TYPES.filter((t) => t.roles.includes(role));
const loading = prefsLoading || pushLoading;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
{onBack && (
<button
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
onClick={onBack}
>
Назад
</button>
)}
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
</div>
{/* Push toggle */}
<Panel className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-[var(--color-accent)]" />
<div>
<p className="text-sm font-medium">Push-уведомления</p>
<p className="text-xs text-[var(--color-text-muted)]">
{isSupported
? isSubscribed
? "Включены — вы получаете уведомления на устройстве"
: "Выкл — нажмите чтобы включить"
: "Не поддерживаются в этом браузере"}
</p>
</div>
</div>
{isSupported && (
<button
className={`relative h-6 w-11 rounded-full transition-colors ${
isSubscribed ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`}
disabled={pushLoading}
onClick={isSubscribed ? unsubscribe : subscribe}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
isSubscribed ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
)}
</div>
</Panel>
{/* Type preferences */}
<Panel className="p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Settings className="h-4 w-4" />
Что уведомлять
</h3>
<div className="space-y-3">
{visibleTypes.map(({ key, label, description }) => (
<label key={key} className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
</div>
<button
className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${
prefs[key] ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`}
disabled={prefsLoading}
onClick={() => updatePref(key, !prefs[key])}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
prefs[key] ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</label>
))}
</div>
</Panel>
</div>
);
}

View File

@ -2,9 +2,7 @@ import React from "react";
import { formatDateTime } from "../../utils/formatters"; import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button"; import { Button } from "../UI/Button";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
import { import {
getOrderGroupDeliveryStatusLabel, getOrderGroupDeliveryStatusLabel,
getOrderGroupDisplayStatusLabel, getOrderGroupDisplayStatusLabel,
@ -39,57 +37,6 @@ const renderList = (values) => {
const renderValue = (value) => value || "Нет данных"; const renderValue = (value) => value || "Нет данных";
const parseOrderList = (order) => {
if (!order) return [];
// Try orderList first (Supabase JSONB array of positions)
if (order.orderList) {
let parsed = order.orderList;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { /* ignore */ }
}
if (Array.isArray(parsed)) return parsed;
}
// Fallback: orderListStructured (JSONB with { orders: [...] })
if (order.orderListStructured) {
let parsed = order.orderListStructured;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { /* ignore */ }
}
if (parsed && Array.isArray(parsed.orders)) return parsed.orders;
}
// Fallback: sourceOrders (1C exchange data)
// Collect orderList from ALL source orders, not just the first one
if (order.sourceOrders) {
let parsed = order.sourceOrders;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { /* ignore */ }
}
if (Array.isArray(parsed) && parsed.length > 0) {
const allItems = [];
for (const src of parsed) {
if (src && Array.isArray(src.orderList)) {
for (const ol of src.orderList) {
if (ol && (ol.items || ol.nom || ol.name)) {
allItems.push(ol);
}
}
}
}
if (allItems.length > 0) return allItems;
// Legacy: return whole array if no orderList structure
if (parsed[0].orderList && Array.isArray(parsed[0].orderList)) {
return parsed[0].orderList;
}
return parsed;
}
}
return [];
};
const getErrorMessage = (error, fallbackMessage) => { const getErrorMessage = (error, fallbackMessage) => {
if (!error) { if (!error) {
return fallbackMessage; return fallbackMessage;
@ -151,14 +98,6 @@ export const getNextSelectableDateKey = (referenceDate = new Date()) => {
return toDateKey(current); return toDateKey(current);
}; };
const normalizePhoneForTel = (phone) => {
const cleaned = String(phone || "").trim();
if (!cleaned) return "";
if (cleaned.startsWith("+7")) return cleaned;
if (cleaned.startsWith("8")) return "+7" + cleaned.slice(1);
return "+7" + cleaned;
};
const isFutureDeliveryDate = (value) => { const isFutureDeliveryDate = (value) => {
const parsedDate = fromDateKey(value); const parsedDate = fromDateKey(value);
@ -246,209 +185,16 @@ const normalizeDateForInput = (value) => {
return ""; return "";
}; };
const CollapsibleOrderComposition = ({ order }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const orders = parseOrderList(order);
const totalPositions = orders.reduce((sum, o) => sum + (o.items?.length || 0), 0);
return (
<div className="space-y-3">
<button
type="button"
className="flex w-full items-center justify-between text-left"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="font-semibold">Состав заказа</span>
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
{totalPositions > 0 ? `${totalPositions} поз.` : ''}
<svg
className="h-4 w-4 transition-transform"
style={{ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{isExpanded && (
<div className="space-y-3">
{!orders.length ? (
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
) : (
orders.map((orderItem, idx) => (
<div key={idx} className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<div className="mb-3 pb-2 border-b border-[var(--color-border)]">
<p className="font-bold text-[var(--color-text)] text-sm">{orderItem.nom || orderItem.name || `Заказ ${idx + 1}`}</p>
</div>
{orderItem.items && orderItem.items.length > 0 ? (
<div className="space-y-2">
{orderItem.items.map((item, itemIdx) => (
<div key={itemIdx} className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm">
<span className="text-[var(--color-text)] min-w-0">{item.product_name || item.name || item.title || ''}</span>
<span className="text-[var(--color-text-muted)] whitespace-nowrap text-right">
{item.product_quantity || item.quantity || item.count || item.amount || ""} {item.product_ed || item.unit || ""}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
)}
</div>
))
)}
</div>
)}
</div>
);
};
const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoice, setFormMessage }) => {
const [showConfirm, setShowConfirm] = React.useState(false);
const isPaidStorage = (order.deliveryStatus || order.delivery_status) === "paid_storage";
if (isPaidStorage) {
return (
<Panel className="space-y-4 p-5">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 rounded-full bg-[var(--color-warning)]"></span>
<strong>Платное хранение</strong>
</div>
{order.paidStorageAt && (
<p className="text-sm text-[var(--color-text-muted)]">
Переведено: {formatDateTime(order.paidStorageAt)}
</p>
)}
<Button
variant="secondary"
onClick={() => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "pending_confirmation",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось отменить платное хранение");
} else {
setFormMessage("Платное хранение отменено");
}
});
}}
disabled={isSavingDeliveryChoice}
>
Отменить платное хранение
</Button>
</Panel>
);
}
return (
<Panel className="space-y-4 p-5">
<div>
<strong>Платное хранение</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Переведите заказ в статус платного хранения, если клиент не забрал товар в срок.
</p>
</div>
{showConfirm ? (
<div className="space-y-3 rounded-2xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4">
<p className="text-sm font-medium">Перевести заказ в платное хранение? Клиент получит уведомление.</p>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "paid_storage",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("Заказ переведён в платное хранение");
setShowConfirm(false);
}
});
}}
disabled={isSavingDeliveryChoice}
>
Да, перевести
</Button>
<Button
variant="secondary"
onClick={() => setShowConfirm(false)}
disabled={isSavingDeliveryChoice}
>
Отмена
</Button>
</div>
</div>
) : (
<Button
variant="secondary"
onClick={() => setShowConfirm(true)}
disabled={isSavingDeliveryChoice}
>
Перевести в платное хранение
</Button>
)}
</Panel>
);
};
const PROBLEM_REASONS = [
{ value: "client_absent", label: "Клиент не принял", description: "Клиент отказался или не вышел на связь" },
{ value: "damage", label: "Повреждение заказа", description: "Товар повреждён при транспортировке" },
{ value: "wrong_address", label: "Неверный адрес", description: "Адрес доставки указан неверно" },
{ value: "other", label: "Другое", description: "Иная причина проблемы доставки" },
];
const ProblemReasonModal = ({ onSelect, onCancel }) => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
<Panel className="mx-4 w-full max-w-md space-y-4 p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold">Причина проблемы</h3>
<p className="text-sm text-[var(--color-text-muted)]">Укажите причину возникшей проблемы с доставкой.</p>
<div className="space-y-2">
{PROBLEM_REASONS.map((reason) => (
<button
key={reason.value}
type="button"
className="w-full rounded-[16px] border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]"
onClick={() => onSelect(reason.value, reason.label)}
>
<span className="font-medium">{reason.label}</span>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">{reason.description}</p>
</button>
))}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onCancel}>Отмена</Button>
</div>
</Panel>
</div>
);
export const OrderDetailPanel = ({ export const OrderDetailPanel = ({
order, order,
canManageDelivery = false, canManageDelivery = false,
onSaveManualDeliveryChoice, onSaveManualDeliveryChoice,
isSavingDeliveryChoice = false, isSavingDeliveryChoice = false,
drivers = [],
onAssignDriver,
onChangeDeliveryStatus,
userRole,
}) => { }) => {
const [problemReason, setProblemReason] = React.useState(null);
const [deliveryDate, setDeliveryDate] = React.useState(""); const [deliveryDate, setDeliveryDate] = React.useState("");
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]); const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const [formMessage, setFormMessage] = React.useState(""); const [formMessage, setFormMessage] = React.useState("");
const [shipmentState, setShipmentState] = React.useState(null);
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
const [driverMessage, setDriverMessage] = React.useState("");
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []); const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
const [currentMonth, setCurrentMonth] = React.useState(() => { const [currentMonth, setCurrentMonth] = React.useState(() => {
const existingDeliveryDate = fromDateKey(order?.deliveryDate); const existingDeliveryDate = fromDateKey(order?.deliveryDate);
@ -470,11 +216,6 @@ export const OrderDetailPanel = ({
); );
const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date())); const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
React.useEffect(() => {
setSelectedDriverId(order?.assignedDriverId || "");
setIsEditingDate(false);
}, [order?.id, order?.assignedDriverId]);
React.useEffect(() => { React.useEffect(() => {
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate); const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
const nextSelectableDateKey = getNextSelectableDateKey(); const nextSelectableDateKey = getNextSelectableDateKey();
@ -494,18 +235,12 @@ export const OrderDetailPanel = ({
); );
} }
const isDeliveryAgreed = ["agreed", "driver_assigned", "loaded", "on_route", "delivered"].includes(order.deliveryStatus || order.delivery_status); const isDeliveryAgreed = (order.deliveryStatus || order.delivery_status) === "agreed";
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
const [isEditingDate, setIsEditingDate] = React.useState(false);
const agreedDeliveryLabel = [ const agreedDeliveryLabel = [
formatDeliveryDateDisplay(order.deliveryDate), formatDeliveryDateDisplay(order.deliveryDate),
order.deliveryTime || order.deliveryHalfDay, order.deliveryTime || order.deliveryHalfDay,
].filter((value) => value && value !== "Нет данных").join(" · "); ].filter((value) => value && value !== "Нет данных").join(" · ");
const handleShipmentChange = React.useCallback((state) => {
setShipmentState(state);
}, []);
const handleSaveDeliveryChoice = async () => { const handleSaveDeliveryChoice = async () => {
if (!deliveryDate || !deliveryTime) { if (!deliveryDate || !deliveryTime) {
setFormMessage("Укажите дату и половину дня доставки."); setFormMessage("Укажите дату и половину дня доставки.");
@ -535,30 +270,6 @@ export const OrderDetailPanel = ({
} }
}; };
const handleAssignDriver = async () => {
if (!selectedDriverId) {
setDriverMessage("Выберите водителя");
return;
}
if (!order.deliveryDate) {
setDriverMessage("Сначала укажите дату и время доставки.");
return;
}
setDriverMessage("");
const response = await onAssignDriver({
orderGroupId: order.id,
driverId: selectedDriverId,
});
if (!response.success) {
setDriverMessage(response.error || "Не удалось назначить водителя");
} else {
setDriverMessage("Водитель назначен");
}
};
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<Panel className="space-y-5 p-6"> <Panel className="space-y-5 p-6">
@ -577,76 +288,61 @@ export const OrderDetailPanel = ({
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge> <Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
</div> </div>
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-3"> <div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 md:grid-cols-2">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Дата доставки Дата доставки
</p> </p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{formatDeliveryDateDisplay(order.deliveryDate)}</p> <p className="mt-1 text-xl font-semibold">{formatDeliveryDateDisplay(order.deliveryDate)}</p>
</div> </div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Время доставки Время доставки
</p> </p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p> <p className="mt-1 text-xl font-semibold">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Водитель
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{order.assignedDriverId ? renderValue(order.assignedDriverName) : "Не назначен"}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Телефон
</p>
<a
href={`tel:${normalizePhoneForTel(order.customerPhone)}`}
className="mt-1 block text-base font-medium !text-[var(--color-accent)] hover:underline"
>
{renderValue(order.customerPhone)}
</a>
</div>
<div className="">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Адрес доставки
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{renderValue(order.deliveryAddress)}</p>
</div> </div>
</div> </div>
<div className="grid gap-x-4 gap-y-2 grid-cols-2 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Номер счёта</p> <p className="text-xs text-[var(--color-text-muted)]">Группа</p>
<p className="font-medium !text-[var(--color-text)]">{renderValue(order.orderNumberSummary)}</p> <p className="mt-1 font-medium">{renderValue(order.groupKey)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p> <p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
<p className="font-medium !text-[var(--color-text)]">{renderValue(order.customerName)}</p> <p className="mt-1 font-medium">{renderValue(order.customerName)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Дата счёта</p> <p className="text-xs text-[var(--color-text-muted)]">Телефон</p>
<p className="font-medium !text-[var(--color-text)]">{renderValue(order.customerDate)}</p> <p className="mt-1 font-medium">{renderValue(order.customerPhone)}</p>
</div>
<div>
<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>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p> <p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p>
<p className="font-medium !text-[var(--color-text)]">{order.ordersCount ?? 0}</p> <p className="mt-1 font-medium">{order.ordersCount ?? 0}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Готово</p> <p className="text-xs text-[var(--color-text-muted)]">Готово</p>
<p className="font-medium !text-[var(--color-text)]">{order.readyCount ?? 0}</p> <p className="mt-1 font-medium">{order.readyCount ?? 0}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Не готово</p> <p className="text-xs text-[var(--color-text-muted)]">Не готово</p>
<p className="font-medium !text-[var(--color-text)]">{order.notReadyCount ?? 0}</p> <p className="mt-1 font-medium">{order.notReadyCount ?? 0}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p> <p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
<p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p> <p className="mt-1 font-medium">{formatDateTime(order.updatedAt)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Статус доставки</p> <p className="text-xs text-[var(--color-text-muted)]">Статус доставки</p>
<p className="font-medium !text-[var(--color-text)]">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p> <p className="mt-1 font-medium">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}</p>
</div> </div>
</div> </div>
</Panel> </Panel>
@ -661,9 +357,8 @@ export const OrderDetailPanel = ({
: "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."} : "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."}
</p> </p>
</div> </div>
{isDeliveryAgreed && !isEditingDate ? ( {isDeliveryAgreed ? (
<div className="space-y-3"> <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="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 className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
@ -676,17 +371,6 @@ export const OrderDetailPanel = ({
<Badge tone="accent">Согласовано</Badge> <Badge tone="accent">Согласовано</Badge>
</div> </div>
</div> </div>
{canEditDelivery ? (
<Button
variant="secondary"
onClick={() => { setIsEditingDate(true); setFormMessage(""); }}
disabled={isSavingDeliveryChoice}
className="text-sm"
>
Изменить дату доставки
</Button>
) : null}
</div>
) : ( ) : (
<div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"> <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"> <div className="space-y-3 md:relative md:z-30 md:min-w-0 md:flex-1 md:pr-4">
@ -694,7 +378,7 @@ export const OrderDetailPanel = ({
type="button" type="button"
aria-label="Дата доставки" aria-label="Дата доставки"
aria-expanded={isCalendarOpen} 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" 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)} onClick={() => setIsCalendarOpen((current) => !current)}
> >
<span>{formatDateForDisplay(deliveryDate)}</span> <span>{formatDateForDisplay(deliveryDate)}</span>
@ -719,7 +403,7 @@ export const OrderDetailPanel = ({
type="button" type="button"
disabled={!canGoBack} disabled={!canGoBack}
aria-label="Предыдущий месяц" 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" 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))} onClick={() => setCurrentMonth((month) => addMonths(month, -1))}
> >
@ -727,7 +411,7 @@ export const OrderDetailPanel = ({
<button <button
type="button" type="button"
aria-label="Следующий месяц" 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)]" 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))} onClick={() => setCurrentMonth((month) => addMonths(month, 1))}
> >
@ -763,10 +447,10 @@ export const OrderDetailPanel = ({
className={[ className={[
"relative flex aspect-square items-center justify-center rounded-xl border text-sm font-semibold transition", "relative flex aspect-square items-center justify-center rounded-xl border text-sm font-semibold transition",
isSelected isSelected
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]" ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
: isWeekend : isWeekend
? "border-dashed border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]" ? "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)]", : "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" : "", isDisabled ? "cursor-not-allowed opacity-45" : "",
].join(" ")} ].join(" ")}
onClick={() => { onClick={() => {
@ -805,8 +489,8 @@ export const OrderDetailPanel = ({
className={[ className={[
"min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition", "min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition",
deliveryTime === option deliveryTime === option
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]" ? "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)]", : "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
].join(" ")} ].join(" ")}
onClick={() => { onClick={() => {
setDeliveryTime(option); setDeliveryTime(option);
@ -832,278 +516,32 @@ export const OrderDetailPanel = ({
</Panel> </Panel>
) : null} ) : null}
{canManageDelivery && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Назначение водителя</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{(() => {
const ds = order.deliveryStatus || order.delivery_status;
if (["loaded", "on_route", "delivered"].includes(ds)) {
return "Доставка в процессе — сменить водителя нельзя.";
}
return order.assignedDriverId
? "Назначен водитель. Вы можете изменить назначение."
: "Выберите водителя для доставки.";
})()}
</p>
</div>
{order.assignedDriverId ? (
<div className="rounded-[24px] border border-[rgba(59,130,246,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">
{order.assignedDriverName || "Неизвестно"}
</p>
</div>
<Badge tone="accent">Назначен</Badge>
</div>
</div>
) : null}
{(() => {
const ds = order.deliveryStatus || order.delivery_status;
const isDriverLocked = ["loaded", "on_route", "delivered"].includes(ds);
return !isDriverLocked ? (
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
<Select
className="h-[46px] py-0"
value={selectedDriverId}
onChange={(e) => {
setSelectedDriverId(e.target.value);
setDriverMessage("");
}}
disabled={isSavingDeliveryChoice}
>
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>{driver.name || driver.email}</option>
))}
</Select>
<Button
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
onClick={handleAssignDriver}
disabled={isSavingDeliveryChoice || !selectedDriverId}
>
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"}
</Button>
</div>
) : null;
})()}
{driverMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
) : null}
</Panel>
) : null}
{["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Измените статус, если водитель забыл обновить или нужна корректировка.
</p>
</div>
<div className="flex flex-wrap gap-2">
{[
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
{ value: "loaded", label: "Загружено", manual: true },
{ value: "on_route", label: "В пути", manual: true },
{ value: "delivered", label: "Доставлено", manual: true },
{ value: "problem", label: "Проблема", manual: true },
{ value: "cancelled", label: "Отменено", manual: true },
].map((statusOption) => {
const isCurrent = (order.deliveryStatus || order.delivery_status) === statusOption.value;
const isClickable = statusOption.manual !== false && !isCurrent;
return (
<div key={statusOption.value} className="relative group">
<Button
variant={isCurrent ? "primary" : "secondary"}
onClick={() => {
if (!isClickable) {
setFormMessage(statusOption.hint || "");
return;
}
setFormMessage("");
onChangeDeliveryStatus({
orderGroupId: order.id,
status: statusOption.value,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
}
});
}}
disabled={isSavingDeliveryChoice}
>
{statusOption.label}
</Button>
</div>
);
})}
</div>
{formMessage ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null}
</Panel>
) : null}
{["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<PaidStoragePanel
order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingDeliveryChoice={isSavingDeliveryChoice}
setFormMessage={setFormMessage}
/>
) : null}
{userRole === "driver" && order ? (
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} />
) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Обновите статус по мере выполнения доставки.
</p>
</div>
{problemReason !== null ? (
<ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "problem",
details: { reason: reasonValue, reasonLabel },
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("Статус обновлён: проблема — " + reasonLabel);
}
setProblemReason(null);
});
}}
onCancel={() => setProblemReason(null)}
/>
) : null}
<div className="flex flex-wrap gap-2">
{(() => {
const currentStatus = order.deliveryStatus || order.delivery_status;
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let availableButtons = [];
if (currentStatus === "driver_assigned") {
availableButtons = [
{ value: "loaded", label: "Загружено" },
{ value: "problem", label: "Проблема" },
];
} else if (isOnRoute) {
availableButtons = [
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
];
} else if (currentStatus === "delivered" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
availableButtons = [];
} else {
availableButtons = [
{ value: "loaded", label: "Загружено" },
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
];
}
return availableButtons.map((statusOption) => (
<Button
key={statusOption.value}
variant={currentStatus === statusOption.value ? "primary" : "secondary"}
onClick={() => {
if (statusOption.value === "problem") {
setProblemReason("selecting");
return;
}
onChangeDeliveryStatus({
orderGroupId: order.id,
status: statusOption.value,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
}
});
}}
disabled={isSavingDeliveryChoice}
>
{statusOption.label}
</Button>
));
})()}
</div>
{formMessage ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null}
</Panel>
) : null}
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
<strong>Номера заказов</strong> <strong>Номера заказов</strong>
{renderList(order.orderNumbers)} {renderList(order.orderNumbers)}
</Panel> </Panel>
<Panel className="space-y-4 p-5"> <Panel className="space-y-4 p-5">
<CollapsibleOrderComposition order={order} />
</Panel>
{userRole !== "driver" ? (
<Panel className="space-y-4 p-5">
<strong>Дополнительные данные</strong> <strong>Дополнительные данные</strong>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{order.firstSmsSentAt ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">1-е SMS отправлено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.firstSmsSentAt)}</p>
</div>
) : null}
{order.secondSmsSentAt ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">2-е SMS отправлено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.secondSmsSentAt)}</p>
</div>
) : null}
{!order.firstSmsSentAt && !order.secondSmsSentAt ? (
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p> <p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">Нет</p> <p className="mt-1 font-medium">{order.smsSentAt ? "Да" : "Нет"}</p>
</div>
) : null}
<div>
<p className="text-xs text-[var(--color-text-muted)]">Ручное согласование выполнено</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.manualConfirmationAt ? formatDateTime(order.manualConfirmationAt) : "Нет"}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Платное хранение</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.paidStorageAt ? formatDateTime(order.paidStorageAt) : "Нет"}</p>
</div> </div>
{order.createdFromExchangeAt ? ( {order.createdFromExchangeAt ? (
<div> <div>
<p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p> <p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p>
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.createdFromExchangeAt)}</p> <p className="mt-1 font-medium">{formatDateTime(order.createdFromExchangeAt)}</p>
</div>
) : null}
{order.sourceKey ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">Ключ источника</p>
<p className="mt-1 font-medium">{order.sourceKey}</p>
</div> </div>
) : null} ) : null}
</div> </div>
</Panel> </Panel>
) : null}
</div> </div>
); );
}; };

View File

@ -6,7 +6,7 @@ import { Panel } from "../UI/Panel";
import { Select } from "../UI/Select"; import { Select } from "../UI/Select";
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
const getManagerOptions = (users) => getUsers(users).filter((user) => user.role === "manager" || user.role === "admin" || user.role === "mega_admin"); const getManagerOptions = (users) => getUsers(users).filter((user) => user.role === "manager" || user.role === "admin");
const initialForm = { const initialForm = {
orderNumber: "", orderNumber: "",
customerName: "", customerName: "",
@ -31,7 +31,7 @@ export const OrderEditorPanel = ({
}) => { }) => {
const [form, setForm] = React.useState(initialForm); const [form, setForm] = React.useState(initialForm);
const [isCreateMode, setIsCreateMode] = React.useState(createOnly); const [isCreateMode, setIsCreateMode] = React.useState(createOnly);
const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin" || currentUser.role === "mega_admin"; const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin";
const managerOptions = getManagerOptions(users); const managerOptions = getManagerOptions(users);
React.useEffect(() => { React.useEffect(() => {

View File

@ -1,24 +1,34 @@
import React from "react"; import React from "react";
import { Badge } from "../UI/Badge";
import { Input } from "../UI/Input"; import { Input } from "../UI/Input";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { DatePicker } from "../UI/DatePicker";
export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = [] }) => { export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
const statusValue = filters.displayStatus || filters.status || "all"; const statusValue = filters.displayStatus || filters.status || "all";
const selectedStatusLabel = statusOptions.find((option) => option.value === statusValue)?.label || statusValue; const selectedStatusLabel = statusOptions.find((option) => option.value === statusValue)?.label || statusValue;
const [isStatusOpen, setIsStatusOpen] = React.useState(false); const [isStatusOpen, setIsStatusOpen] = React.useState(false);
const statusMenuRef = React.useRef(null); const statusMenuRef = React.useRef(null);
React.useEffect(() => { React.useEffect(() => {
if (!isStatusOpen) return undefined; if (!isStatusOpen) {
return undefined;
}
const handlePointerDown = (event) => { const handlePointerDown = (event) => {
if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) setIsStatusOpen(false); if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) {
setIsStatusOpen(false);
}
}; };
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
if (event.key === "Escape") setIsStatusOpen(false); if (event.key === "Escape") {
setIsStatusOpen(false);
}
}; };
document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => { return () => {
document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
@ -29,18 +39,22 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
setFilters((current) => ({ ...current, [key]: value })); setFilters((current) => ({ ...current, [key]: value }));
}; };
const hasDateFilter = filters.dateFrom || filters.dateTo; const activeChips = [statusValue !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean);
const clearDateFilter = () => {
setFilters((current) => ({ ...current, dateFrom: "", dateTo: "" }));
};
return ( return (
<Panel className="p-4"> <Panel className="p-4">
<div className="flex flex-col gap-3"> <div className="grid gap-3 md:grid-cols-[minmax(0,1.6fr)_minmax(12rem,0.7fr)] md:items-end">
{/* Row 1: Status + City + Search */} <Input
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,0.5fr)_minmax(0,1.6fr)] md:items-end"> className="h-[46px] py-0"
placeholder="Поиск по группе, клиенту или телефону"
value={filters.query}
onChange={(event) => updateFilter("query", event.target.value)}
/>
<div ref={statusMenuRef} className="relative 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>
<button <button
type="button" type="button"
aria-haspopup="listbox" aria-haspopup="listbox"
@ -54,16 +68,19 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
onClick={() => setIsStatusOpen((current) => !current)} onClick={() => setIsStatusOpen((current) => !current)}
> >
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span> <span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]"></span> <span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">
</span>
</button> </button>
{isStatusOpen ? ( {isStatusOpen ? (
<div <div
role="listbox" 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-dropdown-surface)] shadow-soft" 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) => { {statusOptions.map((option) => {
const isSelected = option.value === statusValue; const isSelected = option.value === statusValue;
return ( return (
<button <button
key={option.value} key={option.value}
@ -82,9 +99,6 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
}} }}
> >
<span className="min-w-0 flex-1 truncate">{option.label}</span> <span className="min-w-0 flex-1 truncate">{option.label}</span>
<span className="ml-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-xs font-semibold text-[var(--color-text)]">
{option.count || 0}
</span>
{isSelected ? <span className="ml-3 text-[var(--color-accent)]"></span> : null} {isSelected ? <span className="ml-3 text-[var(--color-accent)]"></span> : null}
</button> </button>
); );
@ -92,55 +106,15 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
</div> </div>
) : null} ) : null}
</div> </div>
</div>
{cities.length > 0 && ( {activeChips.length ? (
<select <div className="mt-3 flex flex-wrap gap-2">
value={filters.city || ""} {activeChips.map((chip) => (
onChange={(e) => updateFilter("city", e.target.value)} <Badge key={chip.key}>{chip.label}</Badge>
className="h-[46px] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm outline-none focus:border-[var(--color-accent)]"
>
<option value="">Все города</option>
{cities.map((c) => (
<option key={c} value={c}>{c}</option>
))} ))}
</select>
)}
<Input
className="h-[46px] py-0"
placeholder="Поиск по группе, клиенту или телефону"
value={filters.query}
onChange={(event) => updateFilter("query", event.target.value)}
/>
</div>
{/* Row 2: Date range */}
<div className="flex items-end gap-3 flex-wrap">
<DatePicker
value={filters.dateFrom || ""}
onChange={(v) => updateFilter("dateFrom", v)}
placeholder="С даты"
label="Период: с"
className="min-w-[140px] flex-1 md:max-w-[200px]"
/>
<DatePicker
value={filters.dateTo || ""}
onChange={(v) => updateFilter("dateTo", v)}
placeholder="По дату"
label="по"
className="min-w-[140px] flex-1 md:max-w-[200px]"
/>
{hasDateFilter && (
<button
type="button"
onClick={clearDateFilter}
className="mb-0.5 flex h-[46px] items-center gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)] transition"
>
Сбросить
</button>
)}
</div>
</div> </div>
) : null}
</Panel> </Panel>
); );
}; };

View File

@ -9,16 +9,9 @@ import {
const buildGroupSummary = (group) => { const buildGroupSummary = (group) => {
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`; const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const parts = [orderCountLabel]; const readyCountLabel = `${group.readyCount || 0} готовы`;
if (group.deliveryDate) {
const datePart = group.deliveryTime ? `${group.deliveryDate} · ${group.deliveryTime}` : group.deliveryDate;
parts.push(datePart);
}
if (group.assignedDriverName) {
parts.push(group.assignedDriverName);
}
return parts.join(" · "); return `${orderCountLabel} · ${readyCountLabel}`;
}; };
const renderOrderNumbers = (group) => { const renderOrderNumbers = (group) => {
@ -36,7 +29,6 @@ export const OrdersTable = ({
filters, filters,
setFilters, setFilters,
statusOptions, statusOptions,
cities = [],
}) => { }) => {
return ( return (
<Panel className="p-0"> <Panel className="p-0">
@ -52,15 +44,15 @@ export const OrdersTable = ({
</div> </div>
{filters && setFilters ? ( {filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} /> <OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} />
) : null} ) : null}
</div> </div>
<div className="space-y-3 p-4 md:hidden"> <div className="space-y-3 p-4 md:hidden">
{!orderGroups.length ? ( {!orderGroups.length ? (
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]"> <Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Группы не найдены. Попробуйте изменить поиск или статус. Группы не найдены. Попробуйте изменить поиск или статус.
</div> </Panel>
) : null} ) : null}
{orderGroups.map((group) => ( {orderGroups.map((group) => (
<button <button
@ -108,8 +100,7 @@ export const OrdersTable = ({
<th className="px-5 py-4 font-medium">Клиент</th> <th className="px-5 py-4 font-medium">Клиент</th>
<th className="px-5 py-4 font-medium">Номера</th> <th className="px-5 py-4 font-medium">Номера</th>
<th className="px-5 py-4 font-medium">Статус</th> <th className="px-5 py-4 font-medium">Статус</th>
<th className="px-5 py-4 font-medium">Водитель</th> <th className="px-5 py-4 font-medium">Готовность</th>
<th className="px-5 py-4 font-medium">Дата доставки</th>
<th className="px-5 py-4 font-medium">Обновлён</th> <th className="px-5 py-4 font-medium">Обновлён</th>
</tr> </tr>
</thead> </thead>
@ -139,15 +130,8 @@ export const OrdersTable = ({
<td className="px-5 py-4"> <td className="px-5 py-4">
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge> <Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
</td> </td>
<td className="px-5 py-4 text-sm"> <td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>} {group.readyCount || 0}/{group.ordersCount || 0}
</td>
<td className="px-5 py-4 text-sm">
{group.deliveryDate ? (
<span>{group.deliveryDate}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}
</td> </td>
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]"> <td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
{formatDateTime(group.updatedAt)} {formatDateTime(group.updatedAt)}

View File

@ -1,12 +0,0 @@
export const CRIMEAN_CITIES = [
"Севастополь","Ялта","Алушта","Евпатория","Саки","Феодосия",
"Керчь","Симферополь","Бахчисарай","Судак","Белогорск",
"Красноперекопск","Джанкой","Щёлкино","Гаспра","Гурзуф",
"Кореиз","Ливадия","Массандра","Ореанда","Симеиз",
"Форос","Партенит","Мисхор","Отрадное","Санаторное",
"Васильевка","Куйбышево","Инкерман","Балаклава",
"Утёс","Резниково","Заветное","Хмельницкое","Мирновка",
"Новосёловка","Гвардейское","Красногвардейское",
"Раздольное","Черноморское","Ленино","Советский",
"Нижнегорский","Первомайское","Октябрьское",
];

View File

@ -223,7 +223,7 @@ export const ORDER_STATUS_TRANSITIONS = {
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"], "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"], "Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
"Назначен водитель": ["Загружен", "Проблема доставки"], "Назначен водитель": ["Загружен", "Проблема доставки"],
Загружен: ["Доставлен", "Проблема доставки"], Загружен: ["В пути", "Проблема доставки"],
"В пути": ["Доставлен", "Проблема доставки"], "В пути": ["Доставлен", "Проблема доставки"],
Доставлен: ["Закрыт"], Доставлен: ["Закрыт"],
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"], "Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
@ -248,7 +248,7 @@ export const ROLE_TRANSITION_TARGETS = {
"Закрыт", "Закрыт",
"Отменён", "Отменён",
], ],
driver: ["Загружен", "Доставлен", "Проблема доставки"], driver: ["Загружен", "В пути", "Доставлен", "Проблема доставки"],
admin: ORDER_STATUSES, admin: ORDER_STATUSES,
}; };
@ -267,7 +267,7 @@ export const LOGISTICS_STATUSES = [
"Проблема доставки", "Проблема доставки",
]; ];
export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "Доставлен"]; export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "В пути", "Доставлен"];
export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан."; export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан.";

View File

@ -4,7 +4,6 @@ export const ROLE_LABELS = {
logistician: "Логист", logistician: "Логист",
driver: "Водитель-экспедитор", driver: "Водитель-экспедитор",
admin: "Администратор", admin: "Администратор",
mega_admin: "Суперадмин",
}; };
export const ROLE_PERMISSIONS = { export const ROLE_PERMISSIONS = {
@ -33,10 +32,4 @@ export const ROLE_PERMISSIONS = {
"Управление пользователями и ролями", "Управление пользователями и ролями",
"Логи, ошибки и история действий", "Логи, ошибки и история действий",
], ],
mega_admin: [
"Полный доступ ко всем разделам",
"Управление пользователями и ролями",
"Аналитика и автоматизация",
"Логи ошибок",
],
}; };

View File

@ -1,27 +1,9 @@
import React, { createContext, useContext, useEffect, useRef, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import { demoUsers } from "../data/mockAppData"; import { demoUsers } from "../data/mockAppData";
import { supabase, hasSupabaseConfig } from "../supabaseClient"; import { supabase, hasSupabaseConfig } from "../supabaseClient";
import { logError } from "../utils/errorLogger";
const AuthContext = createContext(null); const AuthContext = createContext(null);
const STORAGE_KEY = "construction-auth-local-user"; const STORAGE_KEY = "construction-auth-local-user";
const SIGNED_OUT_FLAG = "supersam-signed-out";
const encodeLocalAuth = (data) => {
try {
return btoa(encodeURIComponent(JSON.stringify(data)));
} catch {
return null;
}
};
const decodeLocalAuth = (raw) => {
try {
return JSON.parse(decodeURIComponent(atob(raw)));
} catch {
return null;
}
};
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя."; export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору."; export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
@ -32,9 +14,6 @@ const UNKNOWN_EMAIL_ERROR_PATTERNS = [
/invalid login credentials/i, /invalid login credentials/i,
/signup is disabled/i, /signup is disabled/i,
/sign up is disabled/i, /sign up is disabled/i,
/signups not allowed/i,
/email not registered/i,
/email address is not verified/i,
]; ];
const STALE_REFRESH_TOKEN_PATTERNS = [ const STALE_REFRESH_TOKEN_PATTERNS = [
@ -99,59 +78,21 @@ export const mapSessionUserToAuthUser = (sessionUser) => {
id: sessionUser.id, id: sessionUser.id,
email: sessionUser.email, email: sessionUser.email,
name: userMetadata.name || sessionUser.email || "Пользователь", name: userMetadata.name || sessionUser.email || "Пользователь",
role: userMetadata.role || appMetadata.role || null, role: userMetadata.role || appMetadata.role || "manager",
lastLogin: sessionUser.last_sign_in_at || sessionUser.updated_at || null, lastLogin: sessionUser.last_sign_in_at || sessionUser.updated_at || null,
}; };
}; };
export const fetchUserProfile = async (userId) => {
if (!supabase || !userId) return null;
const { data, error } = await supabase
.from("users")
.select("id, email, name, role_id, last_login, roles(name)")
.eq("id", userId)
.maybeSingle();
if (error || !data) return null;
return {
id: data.id,
email: data.email,
name: data.name,
role_info: data.roles,
last_login: data.last_login,
};
};
/** Check if user explicitly signed out (flag survives page refresh via sessionStorage) */
const isSignedOut = () => sessionStorage.getItem(SIGNED_OUT_FLAG) === "1";
/** Clear ALL auth state from storage — called on explicit signOut */
const clearAllAuthStorage = () => {
// Clear Supabase secureStorage keys from sessionStorage
sessionStorage.removeItem("supersam-auth");
sessionStorage.removeItem("supersam-ak");
// Clear local auth cache from localStorage
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem("construction-auth-role-hint");
// Set signed-out flag so page refresh doesn't auto-restore session
sessionStorage.setItem(SIGNED_OUT_FLAG, "1");
};
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
// Supabase mode: always start null, session restore via onAuthStateChange
// Demo mode: restore from localStorage
const [user, setUser] = useState(() => { const [user, setUser] = useState(() => {
if (hasSupabaseConfig) return null;
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
return stored ? decodeLocalAuth(stored) : null; return stored ? JSON.parse(stored) : null;
}); });
const [pendingEmail, setPendingEmail] = useState(""); const [pendingEmail, setPendingEmail] = useState("");
const [isOtpSent, setIsOtpSent] = useState(false); const [isOtpSent, setIsOtpSent] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [authError, setAuthError] = useState(""); const [authError, setAuthError] = useState("");
// Ref to prevent getSession from restoring session after explicit signOut
const signedOutRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!hasSupabaseConfig || !supabase) { if (!hasSupabaseConfig || !supabase) {
return undefined; return undefined;
@ -163,29 +104,11 @@ export const AuthProvider = ({ children }) => {
if (!session?.user) { if (!session?.user) {
setUser(null); setUser(null);
setAuthError(""); setAuthError("");
window.__supersam_user_id__ = null;
return; return;
} }
// Block session restore if user explicitly signed out (ref or sessionStorage flag) const nextUser = mapSessionUserToAuthUser(session.user);
if (signedOutRef.current || isSignedOut()) { setUser(nextUser);
return;
}
const baseUser = mapSessionUserToAuthUser(session.user);
// Expose userId for error logger
window.__supersam_user_id__ = session.user?.id || null;
if (baseUser) {
fetchUserProfile(session.user.id).then((profile) => {
if (profile) {
setUser(mapProfileToAuthUser(profile));
} else {
setUser({ ...baseUser, role: baseUser.role || "manager" });
}
});
} else {
setUser(null);
}
setAuthError(""); setAuthError("");
}); });
@ -193,27 +116,12 @@ export const AuthProvider = ({ children }) => {
if (error && isStaleRefreshTokenError(error)) { if (error && isStaleRefreshTokenError(error)) {
setUser(null); setUser(null);
setAuthError("Сессия истекла. Войдите заново."); setAuthError("Сессия истекла. Войдите заново.");
clearAllAuthStorage();
void supabase.auth.signOut({ scope: "local" }); void supabase.auth.signOut({ scope: "local" });
return; return;
} }
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
if (signedOutRef.current || isSignedOut()) {
return;
}
if (data.session?.user) { if (data.session?.user) {
const baseUser = mapSessionUserToAuthUser(data.session.user); setUser(mapSessionUserToAuthUser(data.session.user));
if (baseUser) {
fetchUserProfile(data.session.user.id).then((profile) => {
if (profile) {
setUser(mapProfileToAuthUser(profile));
} else {
setUser({ ...baseUser, role: baseUser.role || "manager" });
}
});
}
} }
}); });
@ -221,10 +129,10 @@ export const AuthProvider = ({ children }) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (user && isDemoMode) { if (user && !hasSupabaseConfig) {
const encoded = encodeLocalAuth(user); if (encoded) localStorage.setItem(STORAGE_KEY, encoded); localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} }
if (!user && isDemoMode) { if (!user && !hasSupabaseConfig) {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
}, [user]); }, [user]);
@ -240,17 +148,7 @@ export const AuthProvider = ({ children }) => {
}); });
if (error || data?.ok === false) { if (error || data?.ok === false) {
let edgeErrorMessage = data?.error; throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
if (!edgeErrorMessage && typeof Response !== "undefined" && error?.context instanceof Response) {
try {
const cloned = error.context.clone();
const body = await cloned.json();
edgeErrorMessage = body?.error || body?.message;
} catch (e) {
// ignore parse failure
}
}
throw normalizeOtpError(new Error(edgeErrorMessage || (error instanceof Error ? error.message : String(error)) || PROFILE_LOAD_ERROR));
} }
} else { } else {
localStorage.setItem("construction-auth-role-hint", roleHint || "manager"); localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
@ -261,7 +159,6 @@ export const AuthProvider = ({ children }) => {
} catch (error) { } catch (error) {
const normalizedError = normalizeOtpError(error); const normalizedError = normalizeOtpError(error);
setAuthError(normalizedError.message); setAuthError(normalizedError.message);
logError(error, { component: "AuthContext.sendOtp" });
return { success: false, error: normalizedError }; return { success: false, error: normalizedError };
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -277,22 +174,8 @@ export const AuthProvider = ({ children }) => {
}); });
if (error || data?.ok === false) { if (error || data?.ok === false) {
let edgeErrorMessage = data?.error; throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
if (!edgeErrorMessage && typeof Response !== "undefined" && error?.context instanceof Response) {
try {
const cloned = error.context.clone();
const body = await cloned.json();
edgeErrorMessage = body?.error || body?.message;
} catch (e) {
// ignore parse failure
} }
}
throw normalizeOtpError(new Error(edgeErrorMessage || (error instanceof Error ? error.message : String(error)) || PROFILE_LOAD_ERROR));
}
// Clear signedOut flag user is logging in
signedOutRef.current = false;
sessionStorage.removeItem(SIGNED_OUT_FLAG);
if (data?.session?.access_token && data?.session?.refresh_token) { if (data?.session?.access_token && data?.session?.refresh_token) {
const { data: sessionData, error: sessionError } = await supabase.auth.setSession({ const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
@ -304,15 +187,7 @@ export const AuthProvider = ({ children }) => {
throw normalizeOtpError(sessionError); throw normalizeOtpError(sessionError);
} }
const baseUser = mapSessionUserToAuthUser(sessionData.session?.user || data.session.user); setUser(mapSessionUserToAuthUser(sessionData.session?.user || data.session.user));
if (baseUser) {
const profile = await fetchUserProfile(baseUser.id);
if (profile) {
setUser(mapProfileToAuthUser(profile));
} else {
setUser({ ...baseUser, role: baseUser.role || "manager" });
}
}
} else { } else {
setUser(mapSessionUserToAuthUser(data?.user || null)); setUser(mapSessionUserToAuthUser(data?.user || null));
} }
@ -331,7 +206,6 @@ export const AuthProvider = ({ children }) => {
} catch (error) { } catch (error) {
const normalizedError = normalizeOtpError(error); const normalizedError = normalizeOtpError(error);
setAuthError(normalizedError.message); setAuthError(normalizedError.message);
logError(error, { component: "AuthContext.verifyOtp" });
return { success: false, error: normalizedError }; return { success: false, error: normalizedError };
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -339,35 +213,22 @@ export const AuthProvider = ({ children }) => {
}; };
const signOut = async () => { const signOut = async () => {
// Set flag BEFORE signOut to prevent onAuthStateChange/getSession from restoring session
signedOutRef.current = true;
if (hasSupabaseConfig && supabase) { if (hasSupabaseConfig && supabase) {
try { await supabase.auth.signOut();
await supabase.auth.signOut({ scope: "local" });
} catch (e) {
// Ignore session may already be invalid
} }
}
// Hard clear all auth storage so auto-login is impossible
clearAllAuthStorage();
setUser(null); setUser(null);
setPendingEmail(""); setPendingEmail("");
setIsOtpSent(false); setIsOtpSent(false);
setAuthError(""); setAuthError("");
}; };
const isDemoMode = !hasSupabaseConfig && import.meta.env.VITE_ENABLE_DEMO === "true";
const value = { const value = {
user, user,
pendingEmail, pendingEmail,
isOtpSent, isOtpSent,
isLoading, isLoading,
authError, authError,
isDemoMode, isDemoMode: !hasSupabaseConfig,
requestOtp, requestOtp,
verifyOtp, verifyOtp,
signOut, signOut,

View File

@ -816,8 +816,6 @@ export const demoOrderGroups = [
deliveryStatus: "driver_assigned", deliveryStatus: "driver_assigned",
deliveryHalfDay: "Вторая половина дня", deliveryHalfDay: "Вторая половина дня",
smsSentAt: "2026-05-05T11:10:00+00:00", smsSentAt: "2026-05-05T11:10:00+00:00",
firstSmsSentAt: "2026-05-05T11:10:00+00:00",
notificationStatus: "first_sms_sent",
createdFromExchangeAt: "2026-05-05T09:20:00+00:00", createdFromExchangeAt: "2026-05-05T09:20:00+00:00",
sourceKey: "1c-21974", sourceKey: "1c-21974",
legacyCustomerName: null, legacyCustomerName: null,
@ -898,9 +896,6 @@ export const demoOrderGroups = [
deliveryStatus: "on_route", deliveryStatus: "on_route",
deliveryHalfDay: "Вторая половина дня", deliveryHalfDay: "Вторая половина дня",
smsSentAt: "2026-05-05T12:45:00+00:00", smsSentAt: "2026-05-05T12:45:00+00:00",
firstSmsSentAt: "2026-05-05T12:45:00+00:00",
secondSmsSentAt: "2026-05-05T14:00:00+00:00",
notificationStatus: "second_sms_sent",
createdFromExchangeAt: null, createdFromExchangeAt: null,
sourceKey: null, sourceKey: null,
legacyCustomerName: null, legacyCustomerName: null,

View File

@ -1,57 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
const PERIOD_DAYS = { today: 1, "7d": 7, "30d": 30, all: 0 };
const rpcCall = async (fnName, params) => {
if (!hasSupabaseConfig || !supabase) {
throw new Error("Supabase не сконфигурирован");
}
const { data, error } = await supabase.rpc(fnName, params);
if (error) throw error;
return data;
};
export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) => {
const [stats, setStats] = useState(null);
const [statusDist, setStatusDist] = useState([]);
const [dailyTrend, setDailyTrend] = useState([]);
const [driverStats, setDriverStats] = useState([]);
const [economics, setEconomics] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const days = PERIOD_DAYS[period] ?? 30;
const fetchAll = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const params = {
p_days: days,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
};
const [s, sd, dt, ds, econ] = await Promise.all([
rpcCall("admin_delivery_stats", params),
rpcCall("admin_status_distribution", params),
rpcCall("admin_daily_trend", params),
rpcCall("admin_driver_stats", params),
rpcCall("admin_automation_economics", params),
]);
setStats(s?.[0] || null);
setStatusDist(sd || []);
setDailyTrend(dt || []);
setDriverStats(ds || []);
setEconomics(econ?.[0] || null);
} catch (e) {
setError(e.message || "Ошибка загрузки статистики");
} finally {
setIsLoading(false);
}
}, [days, dateFrom, dateTo]);
useEffect(() => { fetchAll(); }, [fetchAll]);
return { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch: fetchAll };
};

View File

@ -1,162 +0,0 @@
import React from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
export function useNotifications(userId) {
const [notifications, setNotifications] = React.useState([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const fetchNotifications = React.useCallback(async () => {
if (!hasSupabaseConfig || !userId) {
setIsLoading(false);
return;
}
const { data, error } = await supabase
.from("notifications")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: false })
.limit(50);
if (!error && data) {
setNotifications(data);
setUnreadCount(data.filter((n) => !n.read).length);
}
setIsLoading(false);
}, [userId]);
// Initial fetch
React.useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
// Realtime subscription
React.useEffect(() => {
if (!hasSupabaseConfig || !userId) return;
const channel = supabase
.channel(`notifications:${userId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload) => {
const newNotif = payload.new;
setNotifications((prev) => [newNotif, ...prev].slice(0, 50));
setUnreadCount((prev) => prev + 1);
}
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
},
(payload) => {
const updated = payload.new;
setNotifications((prev) =>
prev.map((n) => (n.id === updated.id ? updated : n))
);
setUnreadCount((prev) =>
Math.max(0, prev + (updated.read ? -1 : 0) + (prev === 0 && !updated.read ? 1 : 0))
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
// Fix unread count: always recalculate from notifications
React.useEffect(() => {
setUnreadCount(notifications.filter((n) => !n.read).length);
}, [notifications]);
const markAsRead = React.useCallback(
async (notificationId) => {
if (!hasSupabaseConfig) return;
await supabase
.from("notifications")
.update({ read: true })
.eq("id", notificationId);
setNotifications((prev) =>
prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n))
);
},
[]
);
const markAllAsRead = React.useCallback(async () => {
if (!hasSupabaseConfig || !userId) return;
await supabase
.from("notifications")
.update({ read: true })
.eq("user_id", userId)
.eq("read", false);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, [userId]);
return {
notifications,
unreadCount,
isLoading,
markAsRead,
markAllAsRead,
refresh: fetchNotifications,
};
}
export function useNotificationPreferences(userId) {
const [prefs, setPrefs] = React.useState({
push_enabled: true,
order_status_change: true,
driver_assigned: true,
delivery_problem: true,
});
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
if (!hasSupabaseConfig || !userId) {
setIsLoading(false);
return;
}
supabase
.from("notification_preferences")
.select("*")
.eq("user_id", userId)
.single()
.then(({ data, error }) => {
if (data) {
setPrefs({
push_enabled: data.push_enabled,
order_status_change: data.order_status_change,
driver_assigned: data.driver_assigned,
delivery_problem: data.delivery_problem,
});
}
setIsLoading(false);
});
}, [userId]);
const updatePref = React.useCallback(
async (key, value) => {
if (!hasSupabaseConfig || !userId) return;
setPrefs((prev) => ({ ...prev, [key]: value }));
await supabase
.from("notification_preferences")
.upsert({ user_id: userId, [key]: value, updated_at: new Date().toISOString() });
},
[userId]
);
return { prefs, isLoading, updatePref };
}

View File

@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import { assignDriverToOrderGroup, fetchOrderGroups, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository"; import { demoOrderGroups } from "../data/mockAppData";
import { fetchOrderGroups, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
import { import {
buildOrderGroupBuckets, buildOrderGroupBuckets,
filterOrderGroups, filterOrderGroups,
groupOrderGroupsByDate, groupOrderGroupsByDate,
ORDER_GROUP_DISPLAY_STATUS_OPTIONS, ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
getOrderGroupDisplayStatusValue,
} from "../services/orderGroupViews"; } from "../services/orderGroupViews";
import { hasSupabaseConfig } from "../supabaseClient";
const cloneLiveGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => ({ ...group })) : []);
const getErrorMessage = (error, fallbackMessage) => { const getErrorMessage = (error, fallbackMessage) => {
if (!error) { if (!error) {
@ -25,13 +28,17 @@ const getErrorMessage = (error, fallbackMessage) => {
}; };
export const useOrderGroups = () => { export const useOrderGroups = () => {
const [orderGroups, setOrderGroups] = React.useState(() => []); const [orderGroups, setOrderGroups] = React.useState(() =>
hasSupabaseConfig ? [] : cloneLiveGroups(demoOrderGroups),
);
const [filters, setFilters] = React.useState({ const [filters, setFilters] = React.useState({
query: "", query: "",
displayStatus: "all", displayStatus: "all",
}); });
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null); const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(() =>
const [isLoading, setIsLoading] = React.useState(true); hasSupabaseConfig ? null : demoOrderGroups[0]?.id ?? null,
);
const [isLoading, setIsLoading] = React.useState(hasSupabaseConfig);
const [loadError, setLoadError] = React.useState(""); const [loadError, setLoadError] = React.useState("");
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false); const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
@ -39,7 +46,12 @@ export const useOrderGroups = () => {
let cancelled = false; let cancelled = false;
const loadLiveData = async () => { const loadLiveData = async () => {
/* Demo mode removed — always use Supabase */ if (!hasSupabaseConfig) {
setOrderGroups(cloneLiveGroups(demoOrderGroups));
setIsLoading(false);
setLoadError("");
return;
}
setIsLoading(true); setIsLoading(true);
setLoadError(""); setLoadError("");
@ -79,22 +91,7 @@ export const useOrderGroups = () => {
} }
}, [orderGroups, selectedOrderGroupId]); }, [orderGroups, selectedOrderGroupId]);
const statusCounts = React.useMemo(() => { const statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS;
const counts = {};
orderGroups.forEach((group) => {
const status = getOrderGroupDisplayStatusValue(group);
counts[status] = (counts[status] || 0) + 1;
});
return counts;
}, [orderGroups]);
const statusOptions = React.useMemo(() =>
ORDER_GROUP_DISPLAY_STATUS_OPTIONS.map((opt) => ({
...opt,
count: opt.value === "all" ? orderGroups.length : (statusCounts[opt.value] || 0),
})),
[statusCounts, orderGroups]
);
const filteredOrderGroups = React.useMemo( const filteredOrderGroups = React.useMemo(
() => filterOrderGroups(orderGroups, filters), () => filterOrderGroups(orderGroups, filters),
@ -119,7 +116,25 @@ export const useOrderGroups = () => {
setIsSavingDeliveryChoice(true); setIsSavingDeliveryChoice(true);
try { try {
/* Demo mode removed */ 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({ const result = await updateOrderGroupDeliveryChoice({
orderGroupId, orderGroupId,
@ -148,58 +163,6 @@ export const useOrderGroups = () => {
} }
}, []); }, []);
const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => {
setIsSavingDeliveryChoice(true);
try {
const result = await assignDriverToOrderGroup({ orderGroupId, driverId });
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);
}
}, []);
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
setIsSavingDeliveryChoice(true);
try {
const result = await updateDeliveryStatus({ orderGroupId, status, details });
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 { return {
orderGroups, orderGroups,
allOrderGroups: orderGroups, allOrderGroups: orderGroups,
@ -214,8 +177,6 @@ export const useOrderGroups = () => {
orderGroupsByDate, orderGroupsByDate,
deliveryGroupBuckets, deliveryGroupBuckets,
saveManualDeliveryChoice, saveManualDeliveryChoice,
assignDriver,
changeDeliveryStatus,
isSavingDeliveryChoice, isSavingDeliveryChoice,
isLoading, isLoading,
loadError, loadError,

View File

@ -1,122 +0,0 @@
import React from "react";
import { supabase, hasSupabaseConfig } from "../supabaseClient";
const VAPID_PUBLIC_KEY = "BBHRiMfIGQf3EjnnPQ6QS-jS2oset-YMmufJSvw-0a974ULxLaxQqZkfB0ldz-tqH6B9WAIh9Et86i0ezjuedmU";
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from(rawData, (c) => c.charCodeAt(0));
}
export async function subscribeUserToPush(userId) {
if (!hasSupabaseConfig || !userId) return null;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.warn("Push notifications not supported");
return null;
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
} catch (err) {
console.error("Push subscription failed:", err);
return null;
}
}
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return null;
const res = await fetch(`${supabase.supabaseUrl}/functions/v1/subscribe-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
if (!res.ok) {
console.error("Failed to save push subscription:", await res.text());
return null;
}
return subscription;
}
export async function unsubscribeUserFromPush() {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
const endpoint = subscription.endpoint;
await subscription.unsubscribe();
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token) {
await fetch(`${supabase.supabaseUrl}/functions/v1/unsubscribe-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ endpoint }),
});
}
}
export function usePushNotifications(userId) {
const [isSupported, setIsSupported] = React.useState(false);
const [isSubscribed, setIsSubscribed] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
const supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
setIsSupported(supported);
if (!supported) return;
navigator.serviceWorker.ready.then((reg) => {
reg.pushManager.getSubscription().then((sub) => {
setIsSubscribed(!!sub);
});
});
}, []);
const subscribe = React.useCallback(async () => {
if (!isSupported || !userId) return;
setIsLoading(true);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setIsSubscribed(false);
return;
}
const sub = await subscribeUserToPush(userId);
setIsSubscribed(!!sub);
} finally {
setIsLoading(false);
}
}, [isSupported, userId]);
const unsubscribe = React.useCallback(async () => {
setIsLoading(true);
try {
await unsubscribeUserFromPush();
setIsSubscribed(false);
} finally {
setIsLoading(false);
}
}, []);
return { isSupported, isSubscribed, isLoading, subscribe, unsubscribe };
}

View File

@ -4,15 +4,9 @@ import { Badge } from "../components/UI/Badge";
import { Button } from "../components/UI/Button"; import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
import { ThemeToggle } from "../components/UI/ThemeToggle"; import { ThemeToggle } from "../components/UI/ThemeToggle";
import { PwaInstallButton } from "../components/UI/PwaInstallButton";
import { NotificationBell } from "../components/notifications/NotificationBell";
import { NotificationSettings } from "../components/notifications/NotificationSettings";
export const AppShell = ({ export const AppShell = ({
user, user,
onInstallApp,
isInstalled,
isInstallAvailable,
onSignOut, onSignOut,
onOpenGuide, onOpenGuide,
isGuideOpen = false, isGuideOpen = false,
@ -20,33 +14,13 @@ export const AppShell = ({
activeSection, activeSection,
onSectionChange, onSectionChange,
sectionMeta, sectionMeta,
notifications = [],
unreadCount = 0,
onMarkNotificationRead,
onMarkAllNotificationsRead,
children, children,
}) => { }) => {
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1; const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
if (showNotifSettings) {
return (
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-2xl">
<NotificationSettings
userId={user?.id}
userRole={user?.role}
onBack={() => setShowNotifSettings(false)}
/>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8"> <div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0"> <div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
{/* Desktop sidebar */}
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex"> <Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
<div> <div>
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]"> <p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
@ -86,9 +60,7 @@ export const AppShell = ({
</div> </div>
</Panel> </Panel>
{/* Main content area */} <div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0">
{/* Mobile header */}
<Panel className="p-4 xl:hidden"> <Panel className="p-4 xl:hidden">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
@ -99,23 +71,15 @@ export const AppShell = ({
{sectionMeta?.label || "Панель"} {sectionMeta?.label || "Панель"}
</h2> </h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]"> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
{user.name} · {ROLE_LABELS[user.role] || user.role} {user.name} · {ROLE_LABELS[user.role]}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0"> <div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
{onOpenGuide ? ( {onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка"> <Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"} {isGuideOpen ? "Назад" : "?"}
</Button> </Button>
) : null} ) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<ThemeToggle /> <ThemeToggle />
<Button size="sm" variant="ghost" onClick={onSignOut}> <Button size="sm" variant="ghost" onClick={onSignOut}>
Выйти Выйти
@ -124,33 +88,6 @@ export const AppShell = ({
</div> </div>
</Panel> </Panel>
{/* Mobile tab navigation — STICKY TOP */}
{shouldShowMobileNav && (
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6">
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
{navItems.map((item) => (
<button
key={item.key}
className={[
"flex flex-shrink-0 items-center gap-1.5 rounded-[14px] px-3 py-2 text-sm transition",
activeSection === item.key
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
].join(" ")}
onClick={() => onSectionChange(item.key)}
type="button"
>
<span className="truncate font-medium">{item.label}</span>
{item.badge ? (
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
) : null}
</button>
))}
</div>
</div>
)}
{/* Desktop header */}
<Panel className="hidden p-4 md:p-5 xl:block"> <Panel className="hidden p-4 md:p-5 xl:block">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
@ -165,23 +102,15 @@ export const AppShell = ({
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onMarkAsRead={onMarkNotificationRead}
onMarkAllAsRead={onMarkAllNotificationsRead}
onOpenSettings={() => setShowNotifSettings(true)}
/>
<div className="text-right"> <div className="text-right">
<div className="text-sm font-medium">{user.name}</div> <div className="text-sm font-medium">{user.name}</div>
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role] || user.role}</div> <div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role]}</div>
</div> </div>
{onOpenGuide ? ( {onOpenGuide ? (
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка"> <Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
{isGuideOpen ? "Назад" : "?"} {isGuideOpen ? "Назад" : "?"}
</Button> </Button>
) : null} ) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
@ -190,6 +119,31 @@ export const AppShell = ({
{children} {children}
</div> </div>
</div> </div>
{shouldShowMobileNav ? (
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 backdrop-blur xl:hidden">
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
{navItems.map((item) => (
<button
key={item.key}
className={[
"flex min-w-[120px] flex-1 items-center justify-center gap-2 rounded-[18px] px-3 py-3 text-sm transition",
activeSection === item.key
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]",
].join(" ")}
onClick={() => onSectionChange(item.key)}
type="button"
>
<span className="truncate font-medium">{item.label}</span>
{item.badge ? (
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
) : null}
</button>
))}
</div>
</div>
) : null}
</div> </div>
); );
}; };

View File

@ -4,20 +4,17 @@ import { RouterProvider } from "react-router-dom";
import { router } from "./router"; import { router } from "./router";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import ErrorBoundary from "./components/ErrorBoundary";
import { initErrorLogging } from "./utils/errorLogger";
import { registerPwaServiceWorker } from "./hooks/usePwaStatus"; import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
import "./index.css"; import "./index.css";
registerPwaServiceWorker(); registerPwaServiceWorker();
initErrorLogging();
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<ErrorBoundary>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ErrorBoundary>
</AuthProvider> </AuthProvider>
</ThemeProvider>, </ThemeProvider>
</React.StrictMode>,
); );

View File

@ -2,7 +2,6 @@ import React from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow"; import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
import { getInvitationReferenceLabel } from "../components/client/invitationReference"; import { getInvitationReferenceLabel } from "../components/client/invitationReference";
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice"; import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
@ -306,7 +305,6 @@ export const ClientDeliveryPage = () => {
return ( return (
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8"> <main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4"> <div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
{!isChoiceSaved ? (
<Panel className="space-y-3 p-5 sm:p-6"> <Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p> <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1> <h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1>
@ -316,9 +314,6 @@ export const ClientDeliveryPage = () => {
</p> </p>
) : null} ) : null}
</Panel> </Panel>
) : null}
<OrderCompositionPanel invitation={invitation} />
{isChoiceSaved && savedChoiceLabel ? ( {isChoiceSaved && savedChoiceLabel ? (
<Panel className="space-y-2 p-5 sm:p-6"> <Panel className="space-y-2 p-5 sm:p-6">
@ -347,7 +342,7 @@ export const ClientDeliveryPage = () => {
selectedSlot={effectiveSelectedSlot} selectedSlot={effectiveSelectedSlot}
onConfirmChoice={handleSaveChoice} onConfirmChoice={handleSaveChoice}
/> />
) : !isActiveState && !isChoiceSaved ? ( ) : !isChoiceSaved ? (
<DeliveryStateNotice state={invitationState} /> <DeliveryStateNotice state={invitationState} />
) : null} ) : null}

View File

@ -1,79 +1,47 @@
import React from "react"; import React from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner"; import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import { OrdersTable } from "../components/orders/OrdersTable"; import { OrdersTable } from "../components/orders/OrdersTable";
import { AdminDashboard } from "../components/admin/AdminDashboard"; import { Button } from "../components/UI/Button";
import UserManagementPanel from "../components/admin/UserManagementPanel"; import { Modal } from "../components/UI/Modal";
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useNotifications } from "../hooks/useNotifications";
import { usePushNotifications } from "../hooks/usePushNotifications";
import { usePwaStatus } from "../hooks/usePwaStatus";
import { useOrderGroups } from "../hooks/useOrderGroups"; import { useOrderGroups } from "../hooks/useOrderGroups";
import { AppShell } from "../layouts/AppShell"; import { AppShell } from "../layouts/AppShell";
const MEGA_ADMIN_NAV = [
{ key: "analytics", label: "Аналитика", description: "Статистика доставки, графики и показатели.", badge: null },
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: null },
{ key: "users", label: "Пользователи", description: "Управление пользователями и ролями.", badge: null },
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
];
const ROLE_SECTION = { const ROLE_SECTION = {
mega_admin: { key: "analytics", label: "Аналитика" }, manager: {
admin: { key: "analytics", label: "Аналитика", description: "Статистика доставки." }, key: "orders",
manager: { key: "orders", label: "Группы", description: "Реестр групп доставки, поиск и просмотр карточки." }, label: "Группы",
logistician: { key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению." }, description: "Реестр групп доставки, поиск и просмотр карточки.",
driver: { key: "deliveries", label: "Мои доставки", description: "Группы доставки по датам и статусам." }, },
logistician: {
key: "logistics",
label: "Логистика",
description: "Группы доставки по готовности к уведомлению.",
},
driver: {
key: "deliveries",
label: "Мои доставки",
description: "Группы доставки по датам и статусам.",
},
}; };
export const DashboardPage = () => { export const DashboardPage = () => {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const userRole = user?.role; const userRole = user?.role;
const isMegaAdmin = userRole === "mega_admin";
const isAdmin = userRole === "admin" || isMegaAdmin;
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager; const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
const [activeSection, setActiveSection] = React.useState(section.key);
// Active section from URL, fallback to role default const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false);
const activeSection = searchParams.get("tab") || section.key;
const setActiveSection = (key) => {
if (key === section.key) {
setSearchParams({}, { replace: true }); // default tab clean URL
} else {
setSearchParams({ tab: key }, { replace: true });
}
};
const {
notifications,
unreadCount,
isLoading: notifLoading,
markAsRead: markNotificationRead,
markAllAsRead: markAllNotificationsRead,
} = useNotifications(user?.id);
const { isSupported, isSubscribed, subscribe } = usePushNotifications(user?.id);
React.useEffect(() => {
if (isSupported && !isSubscribed && user?.id && Notification.permission === "granted") {
subscribe();
}
}, [isSupported, isSubscribed, user?.id, subscribe]);
const { isInstalled, isInstallAvailable, installApp: onInstallApp } = usePwaStatus();
const { const {
orderGroups, orderGroups,
allOrderGroups, allOrderGroups,
filteredOrderGroups, filteredOrderGroups,
selectedOrderGroup,
selectedOrderGroupId, selectedOrderGroupId,
setSelectedOrderGroupId, setSelectedOrderGroupId,
filters, filters,
@ -81,111 +49,130 @@ export const DashboardPage = () => {
statusOptions, statusOptions,
isLoading, isLoading,
loadError, loadError,
saveManualDeliveryChoice,
isSavingDeliveryChoice,
} = useOrderGroups(); } = useOrderGroups();
const cities = React.useMemo(() => { React.useEffect(() => {
const set = new Set(); setActiveSection(section.key);
for (const g of allOrderGroups) { }, [section.key]);
if (g.city) set.add(g.city);
}
return [...set].sort();
}, [allOrderGroups]);
const openGroupPage = React.useCallback((groupId) => { const openGroupModal = React.useCallback((groupId) => {
navigate("/dashboard/group/" + groupId); setSelectedOrderGroupId(groupId);
}, [navigate]); setIsGroupModalOpen(true);
}, []);
const navItems = isMegaAdmin const navItems = [
? MEGA_ADMIN_NAV {
: userRole === "admin" key: section.key,
? [ label: section.label,
{ key: "analytics", label: "Аналитика", description: "Статистика доставки.", badge: null }, description: section.description,
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: String(allOrderGroups.length || orderGroups.length || 0) }, badge: String(allOrderGroups.length || orderGroups.length || 0),
{ key: "users", label: "Пользователи", description: "Управление пользователями.", badge: null }, },
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
]
: userRole === "logistician"
? [
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
]
: [
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
]; ];
const guideSectionMeta = {
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0]; key: "guide",
const isGuideOpen = false; label: "Справка",
description: "Карта продукта, роли, сценарии и частые вопросы.",
};
const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
const isGuideOpen = activeSection === "guide";
if (!user) { if (!user) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
const renderActiveSection = () => { const renderManagerWorkspace = () => (
<div className="space-y-6 xl:space-y-8">
<OrdersTable
orderGroups={filteredOrderGroups}
selectedOrderGroupId={selectedOrderGroupId}
onOpenOrder={openGroupModal}
filters={filters}
setFilters={setFilters}
statusOptions={statusOptions}
/>
</div>
);
if (activeSection === "analytics") return <div className="space-y-6 xl:space-y-8"><AdminDashboard /></div>; const renderLogisticsWorkspace = () => (
if (activeSection === "users") return <div className="space-y-6 xl:space-y-8"><UserManagementPanel /></div>; <div className="space-y-6 xl:space-y-8">
if (activeSection === "stop_words") return <div className="space-y-6 xl:space-y-8"><StopWordsPanel /></div>; <LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupModal} />
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>; </div>
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>; );
const renderDriverWorkspace = () => (
<div className="space-y-6 xl:space-y-8">
<DriverDeliveryPlanner
orderGroups={allOrderGroups}
onOpenOrder={openGroupModal}
/>
</div>
);
const renderActiveSection = () => {
if (activeSection === "guide") {
return <ProductGuidePanel />;
}
if (userRole === "driver") { if (userRole === "driver") {
return ( return renderDriverWorkspace();
<div className="space-y-6 xl:space-y-8">
<DriverDeliveryPlanner orderGroups={allOrderGroups} onOpenOrder={openGroupPage} currentUser={user} />
</div>
);
} }
if (userRole === "logistician") { if (userRole === "logistician") {
if (activeSection === "orders") { return renderLogisticsWorkspace();
return (
<div className="space-y-6 xl:space-y-8">
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
</div>
);
} }
return (
<div className="space-y-6 xl:space-y-8"> return renderManagerWorkspace();
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
</div>
);
}
return (
<div className="space-y-6 xl:space-y-8">
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
</div>
);
}; };
return ( return (
<AppShell <AppShell
user={user} user={user}
onSignOut={signOut} onSignOut={signOut}
onInstallApp={onInstallApp} onOpenGuide={() => setActiveSection((current) => (current === "guide" ? section.key : "guide"))}
isInstalled={isInstalled} isGuideOpen={isGuideOpen}
isInstallAvailable={isInstallAvailable}
onOpenGuide={undefined}
isGuideOpen={false}
navItems={navItems} navItems={navItems}
activeSection={activeSection} activeSection={activeSection}
onSectionChange={setActiveSection} onSectionChange={setActiveSection}
sectionMeta={activeSectionMeta} sectionMeta={activeSectionMeta}
notifications={notifications}
unreadCount={unreadCount}
onMarkNotificationRead={markNotificationRead}
onMarkAllNotificationsRead={markAllNotificationsRead}
> >
{isLoading && ( {isLoading ? (
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]"> <Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Загружаем данные... Загружаем данные...
</Panel> </Panel>
)} ) : null}
{loadError && ( {loadError ? (
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]"> <Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
Не удалось загрузить данные. Обратитесь к администратору. Не удалось загрузить данные. Обратитесь к администратору.
</Panel> </Panel>
)} ) : null}
{renderActiveSection()} {renderActiveSection()}
<Modal isOpen={isGroupModalOpen} onClose={() => setIsGroupModalOpen(false)}>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">Карточка группы доставки</h3>
</div>
<Button
variant="ghost"
onClick={() => {
setIsGroupModalOpen(false);
}}
>
Закрыть
</Button>
</div>
<OrderDetailPanel
order={selectedOrderGroup}
canManageDelivery={["manager", "logistician", "admin"].includes(userRole)}
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
isSavingDeliveryChoice={isSavingDeliveryChoice}
/>
</div>
</Modal>
</AppShell> </AppShell>
); );
}; };

View File

@ -44,7 +44,6 @@ const baseGroup = {
status: "ready_for_notification", status: "ready_for_notification",
deliveryStatus: "agreed", deliveryStatus: "agreed",
delivery_status: "agreed", delivery_status: "agreed",
assignedDriverId: "u-driver",
deliveryDate: "2026-04-16", deliveryDate: "2026-04-16",
deliveryTime: "Первая половина дня", deliveryTime: "Первая половина дня",
updatedAt: "2026-04-15T09:00:00Z", updatedAt: "2026-04-15T09:00:00Z",
@ -77,7 +76,6 @@ const mockOrderGroupsState = {
loadError: "", loadError: "",
saveManualDeliveryChoice: vi.fn(), saveManualDeliveryChoice: vi.fn(),
isSavingDeliveryChoice: false, isSavingDeliveryChoice: false,
assignDriver: vi.fn(),
}; };
describe("DashboardPage", () => { describe("DashboardPage", () => {
@ -132,7 +130,7 @@ describe("DashboardPage", () => {
); );
expect(markup).toContain("Наборы доставки"); expect(markup).toContain("Наборы доставки");
expect(markup).toContain("Согласовано"); expect(markup).toContain("Готовы к уведомлению");
expect(markup).not.toContain("Управление ботами"); expect(markup).not.toContain("Управление ботами");
expect(markup).not.toContain("рабочая панель"); expect(markup).not.toContain("рабочая панель");
expect(markup).not.toContain("Сегодня"); expect(markup).not.toContain("Сегодня");

View File

@ -1,87 +0,0 @@
import React from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import { Button } from "../components/UI/Button";
import { Panel } from "../components/UI/Panel";
import { useAuth } from "../context/AuthContext";
import { fetchDrivers } from "../services/supabase/userRepository";
import { useOrderGroups } from "../hooks/useOrderGroups";
export const GroupDetailPage = () => {
const { groupId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const userRole = user?.role;
const {
allOrderGroups,
selectedOrderGroupId,
setSelectedOrderGroupId,
saveManualDeliveryChoice,
isSavingDeliveryChoice,
assignDriver,
changeDeliveryStatus,
} = useOrderGroups();
const [drivers, setDrivers] = React.useState([]);
React.useEffect(() => {
if (groupId) {
setSelectedOrderGroupId(groupId);
}
}, [groupId, setSelectedOrderGroupId]);
React.useEffect(() => {
let cancelled = false;
const load = async () => {
const result = await fetchDrivers();
if (cancelled) return;
if (result.data) {
setDrivers(result.data.filter((u) => u.role === "driver"));
}
};
load();
return () => { cancelled = true; };
}, []);
const order = allOrderGroups.find((g) => g.id === groupId) ||
allOrderGroups.find((g) => g.id === selectedOrderGroupId) ||
null;
// Preserve the tab the user came from when going back
const handleGoBack = React.useCallback(() => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/dashboard");
}
}, [navigate]);
return (
<div className="mx-auto w-full max-w-3xl space-y-5">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleGoBack} className="text-sm">
Назад к списку
</Button>
</div>
{order ? (
<OrderDetailPanel
order={order}
canManageDelivery={["manager", "logistician", "admin", "mega_admin"].includes(userRole)}
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
isSavingDeliveryChoice={isSavingDeliveryChoice}
drivers={drivers}
onAssignDriver={assignDriver}
onChangeDeliveryStatus={changeDeliveryStatus}
userRole={userRole}
/>
) : (
<Panel className="p-6 text-sm text-[var(--color-text-muted)]">
Группа доставки не найдена.
</Panel>
)}
</div>
);
};

View File

@ -77,7 +77,7 @@ export const LoginPage = () => {
error={displayError} error={displayError}
/> />
{isDemoMode ? ( {(isDemoMode || import.meta.env.DEV) ? (
<div className="w-full max-w-md space-y-3"> <div className="w-full max-w-md space-y-3">
<p className="text-center text-sm text-[var(--color-text-muted)]"> <p className="text-center text-sm text-[var(--color-text-muted)]">
{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"} {isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}

View File

@ -3,7 +3,6 @@ import { Navigate, createBrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage"; import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
import { DashboardPage } from "./pages/DashboardPage"; import { DashboardPage } from "./pages/DashboardPage";
import { GroupDetailPage } from "./pages/GroupDetailPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { NotFoundPage } from "./pages/NotFoundPage"; import { NotFoundPage } from "./pages/NotFoundPage";
@ -28,10 +27,6 @@ export const router = createBrowserRouter([
path: "dashboard", path: "dashboard",
element: <DashboardPage />, element: <DashboardPage />,
}, },
{
path: "dashboard/group/:groupId",
element: <GroupDetailPage />,
},
{ {
path: "*", path: "*",
element: <NotFoundPage />, element: <NotFoundPage />,

View File

@ -4,28 +4,17 @@ const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.cus
export const DELIVERY_GROUP_STATUS_LABELS = { export const DELIVERY_GROUP_STATUS_LABELS = {
pending_confirmation: "Ожидает согласования", pending_confirmation: "Ожидает согласования",
manual_confirmation_required: "Взят в ручное управление",
agreed: "Согласовано", agreed: "Согласовано",
driver_assigned: "Назначен водитель", driver_assigned: "Назначен водитель",
loaded: "Загружено", loaded: "Загружено",
on_route: "В пути", on_route: "В пути",
delivered: "Доставлено", delivered: "Доставлено",
problem: "Проблема", problem: "Проблема",
paid_storage: "Платное хранение",
cancelled: "Отменено", cancelled: "Отменено",
}; };
export const NOTIFICATION_STATUS_LABELS = {
not_started: "",
link_ready: "Ссылка готова",
first_sms_sent: "1-е приглашение отправлено",
second_sms_sent: "2-е приглашение отправлено",
send_failed: "Ошибка отправки",
confirmed: "Согласовано",
manual_required: "Требуется ручное подтверждение",
};
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"agreed",
"driver_assigned", "driver_assigned",
"loaded", "loaded",
"on_route", "on_route",
@ -33,7 +22,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
"delivered", "delivered",
]; ];
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"]; export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["agreed", "driver_assigned", "loaded", "on_route", "problem"];
const HALF_DAY_LABELS = { const HALF_DAY_LABELS = {
morning: "Первая половина дня", morning: "Первая половина дня",
@ -57,7 +46,7 @@ const normalizeDeliveryHalfDayLabel = (value) => {
return HALF_DAY_LABELS.afternoon; return HALF_DAY_LABELS.afternoon;
} }
return ""; return normalized;
}; };
const parseJsonIfNeeded = (value) => { const parseJsonIfNeeded = (value) => {
@ -139,39 +128,18 @@ export const getOrderGroupDeliveryStatusLabel = (status) =>
export const getOrderGroupDisplayStatusLabel = (group) => { export const getOrderGroupDisplayStatusLabel = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status; const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
const isManualRequired = notificationStatus === "manual_required";
const isStillPending = !deliveryStatus || deliveryStatus === "pending_confirmation" || deliveryStatus === "manual_confirmation_required";
if (isManualRequired && isStillPending) {
return "Требуется ручное управление";
}
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return getOrderGroupDeliveryStatusLabel(deliveryStatus); return getOrderGroupDeliveryStatusLabel(deliveryStatus);
} }
const notificationLabel = NOTIFICATION_STATUS_LABELS[notificationStatus];
if (notificationLabel && notificationStatus !== "link_ready" && notificationStatus !== "not_started") {
return notificationLabel;
}
return getOrderGroupStatusLabel(group?.status); return getOrderGroupStatusLabel(group?.status);
}; };
export const getOrderGroupDisplayStatusValue = (group) => { export const getOrderGroupDisplayStatusValue = (group) => {
const deliveryStatus = group?.deliveryStatus || group?.delivery_status; const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
const notificationStatus = group?.notificationStatus || group?.notification_status;
// Unify manual_required into a single bucket regardless of delivery_status detail if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
const isManualRequired = notificationStatus === "manual_required";
const isStillPending = !deliveryStatus || deliveryStatus === "pending_confirmation" || deliveryStatus === "manual_confirmation_required";
if (isManualRequired && isStillPending) {
return "status:manual_required";
}
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
return `delivery:${deliveryStatus}`; return `delivery:${deliveryStatus}`;
} }
@ -183,7 +151,7 @@ export const isOrderGroupVisibleToDriver = (group) => {
return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus); return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus);
}; };
export const parseGroupDate = (value) => { const parseGroupDate = (value) => {
const normalized = normalizeDate(value); const normalized = normalizeDate(value);
if (!normalized) { if (!normalized) {
@ -251,8 +219,6 @@ export const filterOrderGroups = (groups, filters = {}) => {
getOrderGroupStatusLabel(group.status), getOrderGroupStatusLabel(group.status),
group.deliveryStatus, group.deliveryStatus,
getOrderGroupDeliveryStatusLabel(group.deliveryStatus), getOrderGroupDeliveryStatusLabel(group.deliveryStatus),
group.city,
group.customerAddress,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")) .join(" "))
@ -291,14 +257,6 @@ export const filterOrderGroups = (groups, filters = {}) => {
} }
} }
const cityFilter = (filters.city || "").trim().toLowerCase();
if (cityFilter) {
const groupCity = (group.city || "").toLowerCase();
if (!groupCity.includes(cityFilter) && cityFilter !== groupCity) {
return false;
}
}
if (!query) { if (!query) {
return true; return true;
} }
@ -311,55 +269,43 @@ export const ORDER_GROUP_STATUS_LABELS = {
ready_for_notification: "Готово к уведомлению", ready_for_notification: "Готово к уведомлению",
sms_sent: "SMS отправлены", sms_sent: "SMS отправлены",
manual_work: "Нужна ручная работа", manual_work: "Нужна ручная работа",
ready_to_launch: "Готово к запуску",
}; };
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
{ value: "all", label: "Все статусы" }, { value: "all", label: "Все статусы" },
{ value: "delivery:pending_confirmation", label: DELIVERY_GROUP_STATUS_LABELS.pending_confirmation }, { value: "status:ready_for_notification", label: ORDER_GROUP_STATUS_LABELS.ready_for_notification },
{ value: "delivery:manual_confirmation_required", label: DELIVERY_GROUP_STATUS_LABELS.manual_confirmation_required },
{ value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed }, { value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed },
{ value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned }, { value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned },
{ value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded }, { value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded },
{ value: "delivery:on_route", label: DELIVERY_GROUP_STATUS_LABELS.on_route }, { value: "delivery:on_route", label: DELIVERY_GROUP_STATUS_LABELS.on_route },
{ value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered }, { value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered },
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem }, { value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled }, { value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
{ value: "status:manual_required", label: "Требует ручной обработки" }, { value: "status:sms_sent", label: ORDER_GROUP_STATUS_LABELS.sms_sent },
{ value: "status:second_sms_sent", label: "Повторное SMS" }, { value: "status:manual_work", label: ORDER_GROUP_STATUS_LABELS.manual_work },
{ value: "status:sms_sent", label: "SMS отправлены" },
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
]; ];
export const getOrderGroupStatusLabel = (status) => export const getOrderGroupStatusLabel = (status) =>
ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно"; ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
export const getOrderGroupDeliveryStatusTone = (status) => { export const getOrderGroupDeliveryStatusTone = (status) => {
switch (status) { if (status === "agreed") {
case "pending_confirmation":
return "neutral";
case "manual_confirmation_required":
return "warning";
case "agreed":
return "accent"; return "accent";
case "driver_assigned":
return "info";
case "loaded":
return "info";
case "on_route":
return "warning";
case "delivered":
return "accent";
case "paid_storage":
return "warning";
case "problem":
return "danger";
case "cancelled":
return "danger";
default:
return "neutral";
} }
if (status === "problem") {
return "warning";
}
if (status === "delivered") {
return "accent";
}
if (status === "cancelled") {
return "danger";
}
return "neutral";
}; };
export const groupOrderGroupsByDate = (groups) => { export const groupOrderGroupsByDate = (groups) => {
@ -397,12 +343,6 @@ export const groupOrderGroupsByDate = (groups) => {
}; };
const getBucketKey = (group) => { const getBucketKey = (group) => {
const notificationStatus = group?.notificationStatus || group?.notification_status;
if (notificationStatus === "manual_required") {
return "manual_work";
}
if (group.smsSentAt) { if (group.smsSentAt) {
return "sms_sent"; return "sms_sent";
} }
@ -457,14 +397,6 @@ export const getOrderGroupStatusTone = (group) => {
return getOrderGroupDeliveryStatusTone(deliveryStatus); return getOrderGroupDeliveryStatusTone(deliveryStatus);
} }
const notificationStatus = group?.notificationStatus || group?.notification_status;
if (notificationStatus === "send_failed" || notificationStatus === "manual_required") {
return "warning";
}
if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") {
return "accent";
}
if (group.smsSentAt) { if (group.smsSentAt) {
return "accent"; return "accent";
} }

View File

@ -66,7 +66,7 @@ export const filterOrdersByView = ({ orders, currentUser, filters, now }) => {
return false; return false;
} }
if (currentUser.role === "manager" || currentUser.role === "admin" || currentUser.role === "mega_admin") { if (currentUser.role === "manager" || currentUser.role === "admin") {
return true; return true;
} }

View File

@ -1,5 +1,4 @@
import logger from "../utils/logger"; import logger from "../utils/logger";
import { logError } from "../utils/errorLogger";
export const safeSupabaseCall = async (callback, fallbackMessage = "Ошибка Supabase") => { export const safeSupabaseCall = async (callback, fallbackMessage = "Ошибка Supabase") => {
try { try {
@ -7,8 +6,6 @@ export const safeSupabaseCall = async (callback, fallbackMessage = "Ошибка
return { data, error: null }; return { data, error: null };
} catch (error) { } catch (error) {
logger.error(fallbackMessage, error); logger.error(fallbackMessage, error);
// Also log to client_error_logs for admin visibility
logError(error, { component: "safeSupabaseCall", props: { fallbackMessage } });
return { data: null, error }; return { data: null, error };
} }
}; };

View File

@ -1,66 +0,0 @@
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
import { safeSupabaseCall } from "../safeSupabaseCall";
const requireSupabase = () => {
if (!hasSupabaseConfig || !supabase) {
throw new Error("Supabase не сконфигурирован");
}
return supabase;
};
const ACTION_LABELS = {
status_change: "Смена статуса",
driver_assigned: "Назначение водителя",
driver_removed: "Снятие водителя",
date_assigned: "Назначение даты доставки",
client_confirmed: "Подтверждение клиента",
client_cancelled: "Отмена клиентом",
cancelled: "Отмена доставки",
manual_confirmation: "Ручное подтверждение",
paid_storage: "Платное хранение",
sms_sent: "Отправка SMS",
invitation_created: "Создание приглашения",
};
export const getActionLabel = (action) => ACTION_LABELS[action] || action;
export const logAction = async ({ orderGroupId, action, oldValue = null, newValue = null, details = null }) => {
const client = requireSupabase();
return safeSupabaseCall(async () => {
const { data, error } = await client.rpc("log_action", {
p_order_group_id: orderGroupId,
p_action: action,
p_old_value: oldValue,
p_new_value: newValue,
p_details: details,
});
if (error) throw error;
return data;
}, "Ошибка записи в журнал действий");
};
export const fetchActionLogs = async ({
orderGroupId = null,
action = null,
performedBy = null,
dateFrom = null,
dateTo = null,
limit = 200,
offset = 0,
} = {}) => {
const client = requireSupabase();
return safeSupabaseCall(async () => {
const { data, error } = await client.rpc("get_action_logs", {
p_order_group_id: orderGroupId,
p_action: action,
p_performed_by: performedBy,
p_date_from: dateFrom,
p_date_to: dateTo,
p_limit: limit,
p_offset: offset,
});
if (error) throw error;
return data || [];
}, "Ошибка загрузки журнала действий");
};

View File

@ -1,7 +1,4 @@
import { safeSupabaseCall } from "../safeSupabaseCall"; import { safeSupabaseCall } from "../safeSupabaseCall";
import { CRIMEAN_CITIES } from "../../constants/cities.js";
import { logAction } from "./actionLogService";
import logger from "../../utils/logger";
import { hasSupabaseConfig, supabase } from "../../supabaseClient"; import { hasSupabaseConfig, supabase } from "../../supabaseClient";
import { import {
getOrderGroupDeliveryHalfDay, getOrderGroupDeliveryHalfDay,
@ -80,40 +77,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
: ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay) : ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay)
? rawDeliveryHalfDay ? rawDeliveryHalfDay
: ""; : "";
const deliveryAddress = normalizeText(row.delivery_address);
const extractAddressFromSourceOrders = (sourceOrders) => {
if (!Array.isArray(sourceOrders) || !sourceOrders.length) {
return "";
}
const first = sourceOrders[0];
return normalizeText(first.adress || first.address || "");
};
const deliveryAddress = normalizeText(row.delivery_address) || extractAddressFromSourceOrders(row.source_orders);
const customerAddress = normalizeText(row.customer_address) || "";
const extractCity = (addr) => {
if (!addr) return "";
// 1) explicit marker: г. Ялта, пгт. Куйбышево, etc.
const m = addr.match(/(?:г\.\s|гор\.\s|пос\.\s|с\.\s|дер\.\s|пгт\.\s|город\s|село\s|г\s)\s*([А-ЯЁа-яёA-Za-z\-\s]+?)(?:[,\\s]|$)/i);
if (m) return m[1].trim();
// 2) known city name anywhere in address (case-insensitive)
const lower = addr.toLowerCase();
for (const city of CRIMEAN_CITIES) {
if (lower.includes(city.toLowerCase())) return city;
}
// 3) Бахчисарайский р-н → Бахчисарай
const district = addr.match(/([А-ЯЁа-яё]+)ский\s*(?:р-н|район)/i);
if (district) {
const base = district[1];
for (const city of CRIMEAN_CITIES) {
if (city.toLowerCase().startsWith(base.toLowerCase())) return city;
}
}
// 4) no match → empty (caller falls back to Севастополь)
return "";
};
const city = extractCity(customerAddress) || extractCity(deliveryAddress) || "Севастополь";
return { return {
id: row.id, id: row.id,
@ -132,21 +96,12 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone), customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
customerDate, customerDate,
deliveryAddress, deliveryAddress,
customerAddress,
city,
assignedDriverId: row.assigned_driver_id || null,
assignedDriverName: row.assigned_driver?.name || "",
ordersCount, ordersCount,
readyCount, readyCount,
notReadyCount, notReadyCount,
orderNumbers, orderNumbers,
status: row.status || "draft", status: row.status || "draft",
smsSentAt: row.sms_sent_at || null, smsSentAt: row.sms_sent_at || null,
firstSmsSentAt: row.first_sms_sent_at || null,
secondSmsSentAt: row.second_sms_sent_at || null,
manualConfirmationAt: row.manual_confirmation_at || null,
paidStorageAt: row.paid_storage_at || null,
notificationStatus: normalizeText(row.notification_status),
createdFromExchangeAt: row.created_from_exchange_at || null, createdFromExchangeAt: row.created_from_exchange_at || null,
sourceKey: row.source_key || null, sourceKey: row.source_key || null,
legacyCustomerName: row.legacy_customer_name || null, legacyCustomerName: row.legacy_customer_name || null,
@ -157,8 +112,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
legacyOrdersReady: row.legacy_orders_ready ?? null, legacyOrdersReady: row.legacy_orders_ready ?? null,
legacyOrdersNotReady: row.legacy_orders_not_ready ?? null, legacyOrdersNotReady: row.legacy_orders_not_ready ?? null,
sourceOrders: row.source_orders || null, sourceOrders: row.source_orders || null,
orderList: row.order_list || null,
orderListStructured: row.order_list_structured || null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
deliveryStatus, deliveryStatus,
@ -167,7 +120,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
displaySubtitle: [customerPhone, customerDate].filter(Boolean).join(" · ") || row.group_key || row.id, displaySubtitle: [customerPhone, customerDate].filter(Boolean).join(" · ") || row.group_key || row.id,
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
deliveryDateSource: row.delivery_date_source || null,
deliveryHalfDay: getOrderGroupDeliveryHalfDay({ deliveryHalfDay: getOrderGroupDeliveryHalfDay({
deliveryHalfDay: rawDeliveryHalfDay, deliveryHalfDay: rawDeliveryHalfDay,
deliveryTime: rawDeliveryTime, deliveryTime: rawDeliveryTime,
@ -181,8 +133,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhone, customerPhone,
customerDate, customerDate,
deliveryAddress, deliveryAddress,
customerAddress,
city,
rawDeliveryHalfDay, rawDeliveryHalfDay,
rawDeliveryTime, rawDeliveryTime,
row.delivery_window, row.delivery_window,
@ -198,8 +148,6 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
sourceOrders: row.source_orders, sourceOrders: row.source_orders,
}), }),
getOrderGroupDeliveryStatusLabel(deliveryStatus), getOrderGroupDeliveryStatusLabel(deliveryStatus),
row.notification_status,
extractAddressFromSourceOrders(row.source_orders),
] ]
.filter(Boolean) .filter(Boolean)
.join(" ") .join(" ")
@ -214,198 +162,39 @@ export const updateOrderGroupDeliveryChoice = async ({
}) => { }) => {
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
const updateResult = await client const { data, error } = await client
.from("order_groups") .from("order_groups")
.update({ .update({
delivery_status: "agreed", delivery_status: "agreed",
delivery_date: deliveryDate, delivery_date: deliveryDate,
delivery_time: deliveryTime, delivery_time: deliveryTime,
delivery_date_source: "manual",
notification_status: "confirmed", notification_status: "confirmed",
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })
.eq("id", orderGroupId);
if (updateResult.error) {
throw updateResult.error;
}
const { data, error } = await client
.from("order_groups")
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
.eq("id", orderGroupId) .eq("id", orderGroupId)
.select("*")
.single(); .single();
if (error) { if (error) {
throw error; throw error;
} }
await logAction({ orderGroupId, action: "date_assigned", newValue: "manual: " + deliveryDate + " " + (deliveryTime || ""), details: { delivery_date_source: "manual" } }).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data); return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка сохранения согласования доставки"); }, "Ошибка сохранения согласования доставки");
}; };
export const assignDriverToOrderGroup = async ({
orderGroupId,
driverId,
}) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
logger.debug("[assignDriver] orderGroupId:", orderGroupId, "driverId:", driverId);
// Direct UPDATE — RLS allows manager/logistician/admin
const { data: currentGroup, error: fetchCurrentError } = await client
.from("order_groups")
.select("delivery_status, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
.eq("id", orderGroupId)
.single();
if (fetchCurrentError) {
throw fetchCurrentError;
}
// When assigning a driver, advance status to driver_assigned if currently agreed.
// For loaded/on_route/delivered, keep existing status (driver reassigned mid-delivery).
// For pending/manual statuses, also set driver_assigned (driver chosen before formal agreement).
const currentStatus = currentGroup.delivery_status;
const ADVANCED_STATUSES = ["loaded", "on_route", "delivered"];
const newStatus = driverId
? (ADVANCED_STATUSES.includes(currentStatus) ? undefined : "driver_assigned")
: null; // removing driver → reset to pending
const updates = {
assigned_driver_id: driverId || null,
...(newStatus !== undefined && { delivery_status: newStatus }),
updated_at: new Date().toISOString(),
};
const { error: updateError } = await client
.from("order_groups")
.update(updates)
.eq("id", orderGroupId);
if (updateError) {
throw updateError;
}
// Fetch with driver join for the mapper
const { data, error } = await client
.from("order_groups")
.select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
.eq("id", orderGroupId)
.single();
if (error) {
throw error;
}
const driverName = data?.assigned_driver?.name || driverId || "—";
const oldDriverName = currentGroup?.assigned_driver?.name || currentGroup?.assigned_driver_id || "";
const logPayload = driverId
? { orderGroupId, action: "driver_assigned", oldValue: oldDriverName || undefined, newValue: driverName, details: { driver_name: driverName } }
: { orderGroupId, action: "driver_removed", oldValue: oldDriverName, newValue: "Снят", details: { driver_name: oldDriverName } };
await logAction(logPayload).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка назначения водителя");
};
export const updateDeliveryStatus = async ({ orderGroupId, status, details } = {}) => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
// Fetch current status before any update (needed for audit log)
const { data: current, error: fetchCurrentError } = await client
.from("order_groups")
.select("delivery_status")
.eq("id", orderGroupId)
.single();
if (fetchCurrentError) throw fetchCurrentError;
// Bypass stale RPC for paid_storage transitions
// Server-side RPC still enforces driver-assignment checks that block
// manager/logistician from moving groups into/out of paid_storage.
// RLS policy "order groups update coordination roles" allows
// manager/logistician/admin to update order_groups directly.
if (status === "paid_storage") {
const { error: updateError } = await client
.from("order_groups")
.update({
delivery_status: status,
paid_storage_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq("id", orderGroupId);
if (updateError) throw updateError;
} else if (current.delivery_status === "paid_storage" && status === "pending_confirmation") {
const { error: updateError } = await client
.from("order_groups")
.update({
delivery_status: status,
paid_storage_at: null,
updated_at: new Date().toISOString(),
})
.eq("id", orderGroupId);
if (updateError) throw updateError;
} else {
// All other statuses use the RPC (driver workflows, etc.)
const { error: rpcError } = await client.rpc("update_delivery_status", {
p_order_group_id: orderGroupId,
p_status: status,
});
if (rpcError) throw rpcError;
}
// Fetch updated group
const { data, error } = await client
.from("order_groups")
.select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
.eq("id", orderGroupId)
.single();
if (error) throw error;
// Determine specific action type for better log readability
const logActionType = status === "paid_storage" ? "paid_storage"
: status === "cancelled" ? "cancelled"
: "status_change";
const oldValue = current?.delivery_status || null;
await logAction({ orderGroupId, action: logActionType, oldValue, newValue: status, details: { source: "admin_panel", ...details } }).catch(() => {});
return mapOrderGroupRowToDeliveryGroup(data);
}, "Ошибка обновления статуса доставки");
};
export const fetchOrderGroups = async () => { export const fetchOrderGroups = async () => {
return safeSupabaseCall(async () => { return safeSupabaseCall(async () => {
const client = requireSupabase(); const client = requireSupabase();
const { data, error } = await client const { data, error } = await client
.from("order_groups") .from("order_groups")
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)") .select("*")
.order("updated_at", { ascending: false }); .order("updated_at", { ascending: false });
if (error) { if (error) {
throw error; throw error;
} }
// Load driver names to patch groups where assigned_driver join is missing return (data || []).map(mapOrderGroupRowToDeliveryGroup).filter(Boolean);
const { data: drivers, error: driversError } = await client.rpc("get_drivers");
const driverMap = new Map();
if (!driversError && drivers) {
drivers.forEach((d) => driverMap.set(d.id, d.name || d.email));
}
return (data || []).map((row) => {
const group = mapOrderGroupRowToDeliveryGroup(row);
if (group && group.assignedDriverId && !group.assignedDriverName) {
group.assignedDriverName = driverMap.get(group.assignedDriverId) || "";
}
return group;
}).filter(Boolean);
}, "Ошибка загрузки групп доставки"); }, "Ошибка загрузки групп доставки");
}; };

View File

@ -116,13 +116,10 @@ describe("updateOrderGroupDeliveryChoice", () => {
selectMock.mockReset(); selectMock.mockReset();
singleMock.mockReset(); singleMock.mockReset();
fromMock fromMock.mockReturnValue({ update: updateMock });
.mockReturnValueOnce({ update: updateMock })
.mockReturnValueOnce({ select: selectMock });
updateMock.mockReturnValue({ eq: eqMock }); updateMock.mockReturnValue({ eq: eqMock });
eqMock.mockReturnValueOnce({ error: null, status: 200, statusText: "OK" }) eqMock.mockReturnValue({ select: selectMock });
.mockReturnValueOnce({ single: singleMock }); selectMock.mockReturnValue({ single: singleMock });
selectMock.mockReturnValue({ eq: eqMock });
}); });
it("updates the group directly in order_groups", async () => { it("updates the group directly in order_groups", async () => {
@ -166,7 +163,7 @@ describe("updateOrderGroupDeliveryChoice", () => {
updated_at: expect.any(String), updated_at: expect.any(String),
}); });
expect(eqMock).toHaveBeenCalledWith("id", "group-id"); expect(eqMock).toHaveBeenCalledWith("id", "group-id");
expect(selectMock).toHaveBeenCalledWith("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)"); expect(selectMock).toHaveBeenCalledWith("*");
expect(singleMock).toHaveBeenCalledTimes(1); expect(singleMock).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@ -209,7 +209,7 @@ export const fetchOrders = async () => {
const client = requireSupabase(); const client = requireSupabase();
const { data, error } = await client const { data, error } = await client
.from("orders") .from("orders")
.select("id, order_number, customer, status, delivery_agreement_status, manager_id, logistician_id, assigned_driver_id, ready_for_delivery_at, delivery_flow_started_at, delivery_flow_source, source_order_number, source_order_date, source_customer_name, source_customer_phone, source_customer_email, source_customer_city, source_total_sum, source_paid_at, source_gateway, source_associated_bills_text, source_production_at, source_saw_at, source_glue_at, source_h_glue_at, source_curve_at, source_accept_at, source_ship_at, source_sms_legacy_at, source_payload, delivery_set_key, delivery_set_name, delivery_set_status, delivery_set_ready_at, delivery_ready_reason, created_at, updated_at, order_history(id, action, old_status, new_status, user_id, user_name, metadata, created_at), delivery_slots(id, delivery_date, delivery_time, logistician_id, logistician_name, status, created_at, selected_by_client_at), chat_messages(id, sender_type, sender_name, channel, text, external_message_id, payload, created_at), order_logisticians(order_id, logistician_id)") .select("*, order_history(*), delivery_slots(*), chat_messages(*), order_logisticians(*)")
.order("updated_at", { ascending: false }); .order("updated_at", { ascending: false });
if (error) { if (error) {

View File

@ -1,5 +1,4 @@
import { safeSupabaseCall } from "../safeSupabaseCall"; import { safeSupabaseCall } from "../safeSupabaseCall";
import logger from "../../utils/logger";
import { hasSupabaseConfig, supabase } from "../../supabaseClient"; import { hasSupabaseConfig, supabase } from "../../supabaseClient";
const requireSupabase = () => { const requireSupabase = () => {
@ -42,28 +41,3 @@ export const fetchUsers = async () => {
return (data || []).map(mapUserRowToAppUser).filter(Boolean); return (data || []).map(mapUserRowToAppUser).filter(Boolean);
}, "Ошибка загрузки пользователей"); }, "Ошибка загрузки пользователей");
}; };
export const fetchDrivers = async () => {
return safeSupabaseCall(async () => {
const client = requireSupabase();
const { data, error } = await client.rpc("get_drivers");
logger.debug("[fetchDrivers] rpc raw:", { data, error });
if (error) {
throw error;
}
const mapped = (data || []).map((row) => ({
id: row.id,
email: row.email,
name: row.name,
role: "driver",
lastLogin: null,
botBindings: null,
}));
logger.debug("[fetchDrivers] mapped:", mapped);
return mapped;
}, "Ошибка загрузки водителей");
};

20
src/supabaseClient.js Normal file
View File

@ -0,0 +1,20 @@
import { createClient } from "@supabase/supabase-js";
export const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
export const supabase = hasSupabaseConfig
? createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,
lock: navigator.locks ? undefined : 'no-lock',
},
global: {
headers: { 'x-application-name': 'supersam' },
},
})
: null;

View File

@ -1,123 +0,0 @@
import { createClient } from "@supabase/supabase-js";
export const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
/**
* Secure session storage for Supabase auth tokens.
*
* Security properties:
* - Uses sessionStorage (dies on tab close, not shared across tabs)
* - Tokens are obfuscated with a per-session random key before storage
* - No plaintext tokens in sessionStorage reduces impact of XSS
* - Auto-clears on detection of tampered/missing data
*
* This is NOT as secure as httpOnly cookies (which require server-side SSR),
* but provides significantly better protection than plaintext localStorage:
* - Tokens don't persist across browser restarts
* - Tokens aren't shared across tabs (reduces cross-tab attacks)
* - Obfuscation adds friction for casual XSS token theft
*/
const STORAGE_KEY = "supersam-auth";
const KEY_KEY = "supersam-ak";
function _getKey() {
let key = sessionStorage.getItem(KEY_KEY);
if (!key) {
key = crypto.getRandomValues(new Uint8Array(32)).reduce(
(s, b) => s + b.toString(16).padStart(2, "0"),
""
);
sessionStorage.setItem(KEY_KEY, key);
}
return key;
}
async function _obfuscate(value) {
const key = _getKey();
const enc = new TextEncoder();
const keyData = enc.encode(key);
const valueData = enc.encode(value);
const result = new Uint8Array(valueData.length);
for (let i = 0; i < valueData.length; i++) {
result[i] = valueData[i] ^ keyData[i % keyData.length];
}
return btoa(String.fromCharCode(...result));
}
async function _deobfuscate(obfuscated) {
try {
const key = _getKey();
const enc = new TextEncoder();
const keyData = enc.encode(key);
const raw = Uint8Array.from(atob(obfuscated), (c) => c.charCodeAt(0));
const result = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
result[i] = raw[i] ^ keyData[i % keyData.length];
}
return new TextDecoder().decode(result);
} catch {
// Tampered data clear everything
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(KEY_KEY);
return "";
}
}
const secureStorage = {
getItem: async (key) => {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const data = JSON.parse(raw);
const value = data[key];
if (typeof value !== "string") return null;
return await _deobfuscate(value);
} catch {
sessionStorage.removeItem(STORAGE_KEY);
return null;
}
},
setItem: async (key, value) => {
let data;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
data = raw ? JSON.parse(raw) : {};
} catch {
data = {};
}
data[key] = await _obfuscate(value);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
},
removeItem: async (key) => {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const data = JSON.parse(raw);
delete data[key];
if (Object.keys(data).length === 0) {
sessionStorage.removeItem(STORAGE_KEY);
} else {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
} catch {
sessionStorage.removeItem(STORAGE_KEY);
}
},
};
export const supabase = hasSupabaseConfig
? createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: secureStorage,
autoRefreshToken: true,
detectSessionInUrl: false,
lock: navigator.locks ? undefined : "no-lock",
},
global: {
headers: { "x-application-name": "supersam" },
},
})
: null;

View File

@ -1,186 +0,0 @@
import { supabase, hasSupabaseConfig } from "../supabaseClient";
// Debounce tracking: message -> last timestamp
const recentErrors = new Map();
const DEBOUNCE_MS = 10000;
function getUserId() {
// Try to get from Supabase auth session in sessionStorage
try {
const keys = Object.keys(sessionStorage);
const authKey = keys.find(
(k) => k.startsWith("sb-") && k.endsWith("-auth-token")
);
if (authKey) {
const data = JSON.parse(sessionStorage.getItem(authKey));
return data?.user?.id || null;
}
// Also check supersam secure storage format
const ssKey = keys.find((k) => k === "supersam-auth");
if (ssKey) {
const data = JSON.parse(sessionStorage.getItem(ssKey));
// The secure storage obfuscates values, but the key structure is known
// Fall through to window.__supersam_user_id__ set by AuthContext
}
} catch {
// ignore
}
return null;
}
function isDebounced(message) {
const now = Date.now();
const lastTime = recentErrors.get(message);
if (lastTime && now - lastTime < DEBOUNCE_MS) {
return true;
}
recentErrors.set(message, now);
// Periodically clean up old entries to avoid memory leak
if (recentErrors.size > 100) {
for (const [key, ts] of recentErrors) {
if (now - ts > DEBOUNCE_MS) recentErrors.delete(key);
}
}
return false;
}
async function insertErrorLog(entry) {
try {
if (!hasSupabaseConfig || !supabase) return;
await supabase.from("client_error_logs").insert([entry]);
} catch {
// Fire-and-forget — swallow insertion errors
}
}
function logError(error, componentInfo) {
if (!(error instanceof Error) && typeof error !== "object" && typeof error !== "string") {
return;
}
const message =
typeof error === "string"
? error
: error?.message || String(error);
// Debounce identical messages within 10 seconds
if (isDebounced(message)) return;
const stack =
typeof error === "object" && error !== null ? error.stack || null : null;
let line_number = null;
let column_number = null;
let url = null;
// Try to parse first frame from the stack for line/column/url
if (stack) {
const frameMatch = stack.match(/at\s+.*\((.+):(\d+):(\d+)\)/);
if (frameMatch) {
url = frameMatch[1];
line_number = parseInt(frameMatch[2], 10) || null;
column_number = parseInt(frameMatch[3], 10) || null;
}
}
const entry = {
user_id: getUserId() || window.__supersam_user_id__ || null,
message,
stack,
url,
line_number,
column_number,
error_type:
typeof error === "object" && error !== null
? error.constructor?.name || "Error"
: "String",
component: componentInfo?.component || null,
props: componentInfo?.props
? JSON.stringify(componentInfo.props)
: null,
user_agent: navigator.userAgent,
created_at: new Date().toISOString(),
};
// Fire-and-forget
insertErrorLog(entry);
}
function initErrorLogging(userId) {
// If a userId is provided, store it for future logError calls
if (userId) {
window.__supersam_user_id__ = userId;
}
// Catch synchronous errors
window.onerror = function (message, source, lineno, colno, error) {
const msg = typeof message === "string" ? message : String(message);
if (isDebounced(msg)) return;
const entry = {
user_id: userId || getUserId() || window.__supersam_user_id__ || null,
message: msg,
stack: error?.stack || null,
url: source || null,
line_number: lineno || null,
column_number: colno || null,
error_type: error?.constructor?.name || "Error",
component: null,
props: null,
user_agent: navigator.userAgent,
created_at: new Date().toISOString(),
};
insertErrorLog(entry);
};
// Catch unhandled promise rejections
window.onunhandledrejection = function (event) {
const error = event.reason;
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: String(error);
if (isDebounced(message)) return;
let line_number = null;
let column_number = null;
let url = null;
if (error?.stack) {
const frameMatch = error.stack.match(/at\s+.*\((.+):(\d+):(\d+)\)/);
if (frameMatch) {
url = frameMatch[1];
line_number = parseInt(frameMatch[2], 10) || null;
column_number = parseInt(frameMatch[3], 10) || null;
}
}
const entry = {
user_id: userId || getUserId() || window.__supersam_user_id__ || null,
message,
stack: error?.stack || null,
url,
line_number,
column_number,
error_type:
error instanceof Error
? error.constructor?.name || "Error"
: "UnhandledRejection",
component: null,
props: null,
user_agent: navigator.userAgent,
created_at: new Date().toISOString(),
};
insertErrorLog(entry);
};
}
export { initErrorLogging, logError };

View File

@ -1,12 +1,7 @@
const isDev = typeof import.meta !== "undefined" ? import.meta.env.DEV : true;
const logger = { const logger = {
info: (message, payload) => console.info(`[info] ${message}`, payload ?? ""), info: (message, payload) => console.info(`[info] ${message}`, payload ?? ""),
error: (message, error) => console.error(`[error] ${message}`, error ?? ""), error: (message, error) => console.error(`[error] ${message}`, error ?? ""),
order: (message, payload) => console.log(`[order] ${message}`, payload ?? ""), order: (message, payload) => console.log(`[order] ${message}`, payload ?? ""),
debug: isDev
? (message, payload) => console.debug(`[debug] ${message}`, payload ?? "")
: () => {},
}; };
export default logger; export default logger;

View File

@ -1,16 +0,0 @@
/**
* Role helpers for SuperSam.
* mega_admin inherits all admin permissions.
*/
/** Roles that have admin-level access (mega_admin is a superset of admin) */
export const ADMIN_ROLES = ['admin', 'mega_admin'];
/** Check if the given role has admin-level access */
export const isAdminRole = (role) => ADMIN_ROLES.includes(role);
/** Roles that can manage orders */
export const ORDER_MANAGER_ROLES = ['manager', 'logistician', 'admin', 'mega_admin'];
/** Check if the role can manage orders */
export const canManageOrders = (role) => ORDER_MANAGER_ROLES.includes(role);

View File

@ -1 +0,0 @@
v2.99.0

View File

@ -1,4 +1,4 @@
import { createClient } from "@supabase/supabase-js"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
import { getOrderUpdateForInboundAction } from "./workflow.ts"; import { getOrderUpdateForInboundAction } from "./workflow.ts";
export type ProviderName = "telegram" | "vk" | "messenger_max"; export type ProviderName = "telegram" | "vk" | "messenger_max";
@ -19,13 +19,6 @@ export const createServiceClient = () => {
return createClient(supabaseUrl, serviceRoleKey); return createClient(supabaseUrl, serviceRoleKey);
}; };
/** Create a Supabase client that respects RLS policies (uses anon key). */
export const createAnonClient = () => {
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
const anonKey = Deno.env.get("SUPABASE_ANON_KEY") || "";
return createClient(supabaseUrl, anonKey);
};
export const json = (body: unknown, status = 200) => export const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { new Response(JSON.stringify(body), {
status, status,

View File

@ -190,8 +190,8 @@ export type OrderGroupInvitationSource = {
order_numbers?: string[] | null; order_numbers?: string[] | null;
delivery_status?: string | null; delivery_status?: string | null;
delivery_link?: string | null; delivery_link?: string | null;
source_orders?: unknown[] | null;
}; };
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => { export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
if (invitation.revoked_at) { if (invitation.revoked_at) {
return true; return true;
@ -212,45 +212,6 @@ const parseGroupKey = (groupKey?: string | null) => {
}; };
}; };
const extractOrderItemsFromSourceOrders = (sourceOrders: unknown): Array<{ name: string; quantity: string; items?: unknown[] }> => {
if (!Array.isArray(sourceOrders) || sourceOrders.length === 0) {
return [];
}
const items: Array<{ name: string; quantity: string; items?: unknown[] }> = [];
for (const source of sourceOrders) {
if (!source || typeof source !== "object") {
continue;
}
const record = source as Record<string, unknown>;
const nom = typeof record.nom === "string" ? record.nom : typeof record.name === "string" ? record.name : "";
const orderList = Array.isArray(record.orderList) ? record.orderList : Array.isArray(record.items) ? record.items : [];
if (orderList.length > 0) {
items.push({
name: nom || "Позиция",
quantity: "",
items: orderList.map((item: unknown) => {
if (!item || typeof item !== "object") {
return { name: String(item), quantity: "" };
}
const row = item as Record<string, unknown>;
return {
name: String(row.product_name || row.name || row.title || ""),
quantity: String(row.product_quantity || row.quantity || row.count || row.amount || ""),
};
}),
});
} else if (nom) {
items.push({ name: nom, quantity: "" });
}
}
return items;
};
export const buildPublicOrderGroupInvitationView = ( export const buildPublicOrderGroupInvitationView = (
invitation: DeliveryInvitationRecord, invitation: DeliveryInvitationRecord,
group: OrderGroupInvitationSource, group: OrderGroupInvitationSource,
@ -260,11 +221,6 @@ export const buildPublicOrderGroupInvitationView = (
const customerPhone = group.customer_phone || group.customer?.phone || invitation.customer_phone || parsedKey.phone || 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 : []; const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
const orderItemsFromSource = extractOrderItemsFromSourceOrders(group.source_orders);
const orderItems = orderItemsFromSource.length > 0
? orderItemsFromSource
: orderNumbers.map((number) => ({ name: number, quantity: "" }));
return { return {
orderId: invitation.order_group_id || group.id, orderId: invitation.order_group_id || group.id,
orderGroupId: invitation.order_group_id || group.id, orderGroupId: invitation.order_group_id || group.id,
@ -273,7 +229,7 @@ export const buildPublicOrderGroupInvitationView = (
orderNumber: invitation.order_number || orderNumbers[0] || group.group_key || null, orderNumber: invitation.order_number || orderNumbers[0] || group.group_key || null,
customerName: maskCustomerName(customerName), customerName: maskCustomerName(customerName),
customerPhone: maskPhoneNumber(customerPhone), customerPhone: maskPhoneNumber(customerPhone),
orderItems, orderItems: orderNumbers.map((number) => ({ name: number, quantity: "" })),
availableSlots: invitation.available_slots || [], availableSlots: invitation.available_slots || [],
deliveryDate: invitation.delivery_date || null, deliveryDate: invitation.delivery_date || null,
deliveryTime: invitation.delivery_time || null, deliveryTime: invitation.delivery_time || null,

View File

@ -104,7 +104,12 @@ const resolveAllowedOrigins = (mode: CorsMode) => {
return Array.from(new Set(configured)); return Array.from(new Set(configured));
} }
const currentMode = readEnv("NODE_ENV") || "development";
if (currentMode === "production") {
return []; return [];
}
return [...DEFAULT_LOCAL_ORIGINS];
}; };
export class HttpError extends Error { export class HttpError extends Error {
@ -336,40 +341,6 @@ export const maskOrderNumber = (orderNumber: string | null | undefined) => {
return `${value.slice(-4)}`; return `${value.slice(-4)}`;
}; };
export const isValidUuid = (value: string): boolean => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
};
export const requireUuid = (value: string | undefined | null, label = "id"): string => {
const trimmed = (value || "").trim();
if (!trimmed || !isValidUuid(trimmed)) {
throw new HttpError(400, `Invalid ${label} format`);
}
return trimmed;
};
export const requireSameOrigin = (request: Request, allowedOrigins: string[]) => {
const origin = request.headers.get("origin") || "";
const host = request.headers.get("host") || "";
if (!origin || !host) {
return false;
}
try {
const originHost = new URL(origin).host;
return allowedOrigins.some((allowed) => {
try {
return new URL(allowed).host === originHost;
} catch {
return allowed === origin;
}
});
} catch {
return false;
}
};
export const requireRateLimit = async ( export const requireRateLimit = async (
supabase: { supabase: {
rpc: ( rpc: (

View File

@ -4,7 +4,6 @@ import {
isActiveInvitationState, isActiveInvitationState,
isInvitationExpired, isInvitationExpired,
} from "../_shared/delivery-invitations.ts"; } from "../_shared/delivery-invitations.ts";
import { isValidUuid, requireUuid } from "../_shared/security.ts";
import { createServiceClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts";
import { import {
@ -15,7 +14,6 @@ import {
preflightResponse, preflightResponse,
readJsonBody, readJsonBody,
requireRateLimit, requireRateLimit,
requireSameOrigin,
} from "../_shared/security.ts"; } from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024; const MAX_BODY_BYTES = 8 * 1024;
@ -67,19 +65,6 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
} }
const allowedOriginsForCsrf = ((): string[] => {
const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean);
const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || "";
return [...envOrigins, appUrl].filter(Boolean);
})();
if (!requireSameOrigin(request, allowedOriginsForCsrf)) {
const origin = request.headers.get("origin") || "";
if (origin) {
return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders);
}
}
try { try {
const { body } = await readJsonBody<ConfirmBody>(request, { const { body } = await readJsonBody<ConfirmBody>(request, {
maxBytes: MAX_BODY_BYTES, maxBytes: MAX_BODY_BYTES,
@ -89,14 +74,6 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders); return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
} }
if (body.orderGroupId) {
try {
requireUuid(body.orderGroupId, "orderGroupId");
} catch (e) {
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
}
}
const tokenHash = await hashInvitationToken(body.token); const tokenHash = await hashInvitationToken(body.token);
const supabase = createServiceClient(); const supabase = createServiceClient();
const ipHash = await hashText(getClientIp(request)); const ipHash = await hashText(getClientIp(request));
@ -192,19 +169,6 @@ Deno.serve(async (request) => {
throw groupUpdateError; throw groupUpdateError;
} }
// Log: client confirmed delivery choice
await supabase.from("action_logs").insert({
order_group_id: invitation.order_group_id,
action: "client_confirmed",
old_value: currentGroup.delivery_status,
new_value: "agreed",
details: {
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
source: "auto",
},
});
await insertIntegrationEvent(supabase, { await insertIntegrationEvent(supabase, {
order_id: null, order_id: null,
event_type: "delivery_choice_confirmed", event_type: "delivery_choice_confirmed",

View File

@ -19,7 +19,6 @@ import {
} from "../_shared/security.ts"; } from "../_shared/security.ts";
const MAX_BODY_BYTES = 16 * 1024; const MAX_BODY_BYTES = 16 * 1024;
const MAX_SLOTS = 14;
type CreateInvitationBody = { type CreateInvitationBody = {
orderId?: string; orderId?: string;
@ -151,7 +150,7 @@ const createOrderGroupInvitation = async ({
const publicBaseUrl = resolveRequiredPublicAppUrl(request); const publicBaseUrl = resolveRequiredPublicAppUrl(request);
const url = buildInvitationUrl(publicBaseUrl, token); const url = buildInvitationUrl(publicBaseUrl, token);
const availableSlots = body.availableSlots?.length const availableSlots = body.availableSlots?.length
? normalizeAvailableSlots(body.availableSlots).slice(0, MAX_SLOTS) ? normalizeAvailableSlots(body.availableSlots)
: buildDefaultDatedAvailableSlots(); : buildDefaultDatedAvailableSlots();
const invitationPayload = { const invitationPayload = {
@ -164,7 +163,7 @@ const createOrderGroupInvitation = async ({
customer_phone: customerPhone, customer_phone: customerPhone,
customer_messenger: body.customerMessenger || null, customer_messenger: body.customerMessenger || null,
available_slots: availableSlots, available_slots: availableSlots,
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
sent_at: null, sent_at: null,
}; };
@ -322,7 +321,7 @@ Deno.serve(async (request) => {
customer_phone: body.customerPhone || null, customer_phone: body.customerPhone || null,
customer_messenger: body.customerMessenger || null, customer_messenger: body.customerMessenger || null,
available_slots: normalizeAvailableSlots(body.availableSlots), available_slots: normalizeAvailableSlots(body.availableSlots),
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
sent_at: new Date().toISOString(), sent_at: new Date().toISOString(),
}; };

View File

@ -8,7 +8,6 @@ import {
isInvitationExpired, isInvitationExpired,
} from "../_shared/delivery-invitations.ts"; } from "../_shared/delivery-invitations.ts";
import { createServiceClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { isValidUuid } from "../_shared/security.ts";
import { import {
getClientIp, getClientIp,
getCorsHeaders, getCorsHeaders,

View File

@ -1,5 +0,0 @@
{
"imports": {
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8"
}
}

View File

@ -1,5 +1,6 @@
import { getOrderUpdateForDeliveryInvitationAction } from "../_shared/delivery-invitations.ts"; import {
import { requireUuid } from "../_shared/security.ts"; getOrderUpdateForDeliveryInvitationAction,
} from "../_shared/delivery-invitations.ts";
import { createServiceClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { insertIntegrationEvent } from "../_shared/integration-events.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts";
import { import {
@ -41,12 +42,6 @@ Deno.serve(async (request) => {
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
} }
try {
requireUuid(body.orderId, "orderId");
} catch (e) {
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
}
const supabase = createServiceClient(); const supabase = createServiceClient();
await requireRateLimit(supabase, { await requireRateLimit(supabase, {
scope: "delivery-report", scope: "delivery-report",

View File

@ -1,4 +1,4 @@
import { createAnonClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { import {
getClientIp, getClientIp,
getCorsHeaders, getCorsHeaders,
@ -38,7 +38,7 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
} }
const supabase = createAnonClient(); const supabase = createServiceClient();
const emailHash = await hashText(email); const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request)); const ipHash = await hashText(getClientIp(request));

View File

@ -42,12 +42,6 @@ Deno.serve(async (request) => {
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
} }
try {
requireUuid(body.orderId, "orderId");
} catch (e) {
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
}
const supabase = createServiceClient(); const supabase = createServiceClient();
await requireRateLimit(supabase, { await requireRateLimit(supabase, {
scope: "delivery-transfer", scope: "delivery-transfer",

View File

@ -1,4 +1,4 @@
import { createAnonClient } from "../_shared/chatbot.ts"; import { createServiceClient } from "../_shared/chatbot.ts";
import { import {
getClientIp, getClientIp,
getCorsHeaders, getCorsHeaders,
@ -7,7 +7,6 @@ import {
preflightResponse, preflightResponse,
readJsonBody, readJsonBody,
requireRateLimit, requireRateLimit,
requireSameOrigin,
} from "../_shared/security.ts"; } from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024; const MAX_BODY_BYTES = 8 * 1024;
@ -29,19 +28,6 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
} }
const allowedOriginsForCsrf = ((): string[] => {
const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean);
const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || "";
return [...envOrigins, appUrl].filter(Boolean);
})();
if (!requireSameOrigin(request, allowedOriginsForCsrf)) {
const origin = request.headers.get("origin") || "";
if (origin) {
return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders);
}
}
try { try {
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, { const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
maxBytes: MAX_BODY_BYTES, maxBytes: MAX_BODY_BYTES,
@ -57,7 +43,7 @@ Deno.serve(async (request) => {
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders); return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
} }
const supabase = createAnonClient(); const supabase = createServiceClient();
const emailHash = await hashText(email); const emailHash = await hashText(email);
const ipHash = await hashText(getClientIp(request)); const ipHash = await hashText(getClientIp(request));

View File

@ -1,72 +0,0 @@
-- Patch: Add paid_storage status support for order_groups
-- 1. Add paid_storage_at column to order_groups if not exists
alter table public.order_groups add column if not exists paid_storage_at timestamptz;
-- 2. Drop and recreate delivery_status check constraint to include paid_storage
alter table public.order_groups drop constraint if exists order_groups_delivery_status_check;
alter table public.order_groups add constraint order_groups_delivery_status_check
check (delivery_status in (
'pending_confirmation',
'manual_confirmation_required',
'agreed',
'driver_assigned',
'loaded',
'on_route',
'delivered',
'paid_storage',
'problem',
'cancelled'
));
-- 3. Update update_delivery_status to allow paid_storage without assigned driver
create or replace function public.update_delivery_status(
p_order_group_id uuid,
p_status text
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_assigned_driver_id uuid;
v_current_status text;
begin
select assigned_driver_id, delivery_status
into v_assigned_driver_id, v_current_status
from public.order_groups
where id = p_order_group_id;
-- Allow paid_storage status for any authenticated user with proper role
-- (checked by RLS policy)
if p_status = 'paid_storage' then
update public.order_groups
set delivery_status = p_status,
paid_storage_at = timezone('utc', now()),
updated_at = timezone('utc', now())
where id = p_order_group_id;
return true;
end if;
if v_assigned_driver_id is null then
raise exception 'Группа не назначена водителю';
end if;
if v_assigned_driver_id != auth.uid() then
raise exception 'Вы не назначены на эту доставку';
end if;
update public.order_groups
set delivery_status = p_status,
updated_at = timezone('utc', now())
where id = p_order_group_id;
return true;
end;
$$;
-- 4. Ensure proper grants
revoke execute on function public.update_delivery_status(uuid, text) from anon;
grant execute on function public.update_delivery_status(uuid, text) to authenticated;

View File

@ -147,10 +147,7 @@ create table if not exists public.order_groups (
last_sms_error text, last_sms_error text,
next_notification_check_at timestamptz, next_notification_check_at timestamptz,
delivery_date date, delivery_date date,
delivery_time text, delivery_time text
delivery_address text,
manual_confirmation_at timestamptz,
assigned_driver_id uuid references public.users (id)
); );
create table if not exists public.delivery_invitations ( create table if not exists public.delivery_invitations (
@ -345,9 +342,6 @@ as $$
join public.roles r on r.id = u.role_id join public.roles r on r.id = u.role_id
where u.id = auth.uid() where u.id = auth.uid()
$$; $$;
-- Disable row-level security for this function so it can read users
-- without triggering infinite recursion via users RLS policies.
alter function public.current_role_name() set row_security = off;
create or replace function public.handle_new_user() create or replace function public.handle_new_user()
returns trigger returns trigger
@ -621,16 +615,16 @@ begin
to_jsonb(v_group.order_numbers) ->> 0, to_jsonb(v_group.order_numbers) ->> 0,
nullif(v_group.group_key, '') nullif(v_group.group_key, '')
); );
v_customer_name := case v_customer_name := coalesce(
when length(coalesce(nullif(v_group.customer_name, ''), nullif(v_invitation.customer_name, ''))) > 0 nullif(v_group.customer_name, ''),
then left(coalesce(nullif(v_group.customer_name, ''), nullif(v_invitation.customer_name, '')), 1) || '.' nullif(v_group.customer ->> 'name', ''),
else null nullif(v_invitation.customer_name, '')
end; );
v_customer_phone := case v_customer_phone := coalesce(
when length(coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, ''))) >= 4 nullif(v_group.customer_phone, ''),
then '+7 *** ***-' || right(coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, '')), 2) nullif(v_group.customer ->> 'phone', ''),
else coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, '')) nullif(v_invitation.customer_phone, '')
end; );
select coalesce( select coalesce(
jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')), jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
'[]'::jsonb '[]'::jsonb
@ -970,36 +964,13 @@ using (public.current_role_name() = 'admin');
drop policy if exists "users self or admin" on public.users; drop policy if exists "users self or admin" on public.users;
create policy "users self or admin" on public.users create policy "users self or admin" on public.users
for select for select
using (id = auth.uid()); using (public.current_role_name() = 'admin' or id = auth.uid());
-- Helper to check admin role without RLS recursion.
create or replace function public.is_admin()
returns boolean
language sql
stable
security definer
set search_path = public
as 81937
select exists (
select 1 from public.users u
join public.roles r on r.id = u.role_id
where u.id = auth.uid() and r.name = 'admin'
)
81937;
alter function public.is_admin() set row_security = off;
drop policy if exists "users admin update" on public.users; drop policy if exists "users admin update" on public.users;
create policy "users admin update" on public.users create policy "users admin update" on public.users
for all for all
using (public.is_admin()) using (public.current_role_name() = 'admin')
with check (public.is_admin()); with check (public.current_role_name() = 'admin');
drop policy if exists "users readable by logistics" on public.users;
create policy "users readable by logistics" on public.users
for select
using (
auth.role() in ('authenticated', 'service_role')
);
drop policy if exists "orders select by role" on public.orders; drop policy if exists "orders select by role" on public.orders;
create policy "orders select by role" on public.orders create policy "orders select by role" on public.orders
@ -1101,25 +1072,18 @@ with check (public.current_role_name() in ('manager', 'production_lead', 'logist
drop policy if exists "order groups select by role" on public.order_groups; drop policy if exists "order groups select by role" on public.order_groups;
create policy "order groups select by role" on public.order_groups create policy "order groups select by role" on public.order_groups
for select for select
using ( using (true);
public.current_role_name() in ('manager', 'logistician', 'driver', 'admin')
or exists (
select 1 from public.delivery_invitations di
where di.order_group_id = order_groups.id
and di.state in ('awaiting_choice', 'opened', 'reminder_sent')
)
);
drop policy if exists "order groups update coordination roles" on public.order_groups; drop policy if exists "order groups update coordination roles" on public.order_groups;
create policy "order groups update coordination roles" on public.order_groups create policy "order groups update coordination roles" on public.order_groups
for update for update
using (public.current_role_name() in ('manager', 'logistician', 'admin')) using (public.current_role_name() in ('manager', 'logistician', 'admin'))
with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role'); with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
drop policy if exists "order groups insert service roles" on public.order_groups; drop policy if exists "order groups insert service roles" on public.order_groups;
create policy "order groups insert service roles" on public.order_groups create policy "order groups insert service roles" on public.order_groups
for insert for insert
with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role'); with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
drop policy if exists "slots by order role" on public.delivery_slots; drop policy if exists "slots by order role" on public.delivery_slots;
create policy "slots by order role" on public.delivery_slots create policy "slots by order role" on public.delivery_slots
@ -1213,155 +1177,3 @@ create policy "integration events admin only" on public.integration_events
for all for all
using (public.current_role_name() = 'admin') using (public.current_role_name() = 'admin')
with check (public.current_role_name() = 'admin'); with check (public.current_role_name() = 'admin');
-- RPC для получения списка водителей (обход RLS)
create or replace function public.get_drivers()
returns table (
id uuid,
email text,
name text
)
language plpgsql
security definer
set search_path = public
as $$
begin
return query
select u.id, u.email, u.name
from public.users u
join public.roles r on r.id = u.role_id
where r.name = 'driver'
order by u.name;
end;
$$;
revoke execute on function public.get_drivers() from anon;
grant execute on function public.get_drivers() to authenticated;
-- Audit log for admin actions
create table if not exists public.audit_log (
id uuid primary key default gen_random_uuid(),
actor_id uuid references auth.users (id) on delete set null,
action text not null,
target_type text,
target_id text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default timezone('utc', now())
);
alter table public.audit_log enable row level security;
create policy "audit admin only" on public.audit_log
for all
using (public.current_role_name() = 'admin')
with check (public.current_role_name() = 'admin');
create index if not exists idx_audit_log_actor_id on public.audit_log (actor_id);
create index if not exists idx_audit_log_action on public.audit_log (action);
create index if not exists idx_audit_log_target on public.audit_log (target_type, target_id);
create index if not exists idx_audit_log_created_at on public.audit_log (created_at desc);
-- Trigger: log role changes
create or replace function public.log_role_change()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.audit_log (actor_id, action, target_type, target_id, metadata)
values (
auth.uid(),
tg_op = 'INSERT' then 'role_created'::text else 'role_updated'::text end,
'role',
new.id::text,
jsonb_build_object(
'name', new.name,
'permissions', new.permissions,
'old_name', case when tg_op = 'UPDATE' then old.name else null end
)
);
return new;
end;
$$;
drop trigger if exists on_role_change on public.roles;
create trigger on_role_change
after insert or update on public.roles
for each row
execute function public.log_role_change();
-- Trigger: log user changes
create or replace function public.log_user_change()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.audit_log (actor_id, action, target_type, target_id, metadata)
values (
auth.uid(),
case tg_op
when 'INSERT' then 'user_created'
when 'UPDATE' then 'user_updated'
when 'DELETE' then 'user_deleted'
end,
'user',
coalesce(new.id, old.id)::text,
jsonb_build_object(
'email', coalesce(new.email, old.email),
'name', coalesce(new.name, old.name),
'role_id', coalesce(new.role_id, old.role_id)
)
);
return coalesce(new, old);
end;
$$;
drop trigger if exists on_user_change on public.users;
create trigger on_user_change
after insert or update or delete on public.users
for each row
execute function public.log_user_change();
-- RPC for driver to update delivery status
-- Validates that the requesting user is the assigned driver
create or replace function public.update_delivery_status(
p_order_group_id uuid,
p_status text
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_assigned_driver_id uuid;
v_current_status text;
begin
select assigned_driver_id, delivery_status
into v_assigned_driver_id, v_current_status
from public.order_groups
where id = p_order_group_id;
if v_assigned_driver_id is null then
raise exception 'Группа не назначена водителю';
end if;
if v_assigned_driver_id != auth.uid() then
raise exception 'Вы не назначены на эту доставку';
end if;
update public.order_groups
set delivery_status = p_status,
updated_at = timezone('utc', now())
where id = p_order_group_id;
return true;
end;
$$;
revoke execute on function public.update_delivery_status(uuid, text) from anon;
grant execute on function public.update_delivery_status(uuid, text) to authenticated;

View File

@ -1,61 +0,0 @@
-- Sync manual_required notification_status into unified "Требуется ручное управление" workflow
-- and clear it when logistics takes action (changes delivery_status).
-- 1. Backfill: normalize existing rows where notification_status is manual_required
-- and delivery_status is still pending/manual_confirmation_required.
update public.order_groups
set delivery_status = 'pending_confirmation',
updated_at = timezone('utc', now())
where notification_status = 'manual_required'
and delivery_status = 'manual_confirmation_required';
-- 2. When notification_status becomes manual_required, ensure delivery_status stays pending
-- so the group appears in the unified "Требуется ручное управление" bucket.
create or replace function public.sync_manual_required_notification()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
-- Only act when notification_status changed to manual_required
if new.notification_status = 'manual_required'
and (old is null or old.notification_status is distinct from new.notification_status) then
if new.delivery_status is null or new.delivery_status = 'manual_confirmation_required' then
new.delivery_status := 'pending_confirmation';
end if;
end if;
return new;
end;
$$;
drop trigger if exists sync_manual_required_notification_trigger on public.order_groups;
create trigger sync_manual_required_notification_trigger
before insert or update on public.order_groups
for each row
execute function public.sync_manual_required_notification();
-- 3. When delivery_status is changed by logistics (away from pending/manual_confirmation_required),
-- clear notification_status so the group leaves the manual-work bucket.
create or replace function public.clear_manual_required_on_action()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if old.delivery_status in ('pending_confirmation', 'manual_confirmation_required')
and new.delivery_status not in ('pending_confirmation', 'manual_confirmation_required')
and old.notification_status = 'manual_required' then
new.notification_status := 'confirmed';
end if;
return new;
end;
$$;
drop trigger if exists clear_manual_required_on_action_trigger on public.order_groups;
create trigger clear_manual_required_on_action_trigger
before update on public.order_groups
for each row
when (new.delivery_status is distinct from old.delivery_status)
execute function public.clear_manual_required_on_action();

View File

@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""Webhook listener: Gitea push → auto-deploy main (prod) and dev."""
import http.server
import json
import subprocess
import threading
import os
PORT = 9876
LOG_FILE = "/opt/supersam/deploy.log"
DEPLOYMENTS = {
"refs/heads/main": {
"script": "/opt/supersam/deploy.sh",
"dir": "/opt/supersam",
"branch": "main",
},
"refs/heads/dev": {
"script": "/opt/supersam-dev/deploy.sh",
"dir": "/opt/supersam-dev",
"branch": "dev",
},
}
def run_deploy(ref, config):
script = config["script"]
workdir = config["dir"]
branch = config["branch"]
log = open(LOG_FILE, "a")
log.write(f"\n=== Deploy {branch} @ {__import__('datetime').datetime.now()} ===\n")
log.flush()
result = subprocess.run(
["bash", script],
capture_output=True, text=True, timeout=300,
cwd=workdir,
)
log.write(result.stdout)
if result.stderr:
log.write(result.stderr)
log.write(f"Exit code: {result.returncode}\n")
log.close()
class WebhookHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhook/deploy":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
payload = json.loads(body)
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Invalid JSON")
return
ref = payload.get("ref", "")
config = DEPLOYMENTS.get(ref)
if not config:
self.send_response(200)
self.end_headers()
self.wfile.write(f"Skipping push to {ref}".encode())
return
thread = threading.Thread(target=run_deploy, args=(ref, config))
thread.daemon = True
thread.start()
self.send_response(200)
self.end_headers()
self.wfile.write(f"Deploy triggered for {ref}".encode())
print(f"[webhook] Deploy triggered for {ref}")
def log_message(self, format, *args):
print(f"[webhook] {args[0]}")
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", PORT), WebhookHandler)
print(f"[webhook] Listening on 0.0.0.0:{PORT}")
server.serve_forever()