Compare commits
81 Commits
488e478841
...
3c22eb71ab
| Author | SHA1 | Date |
|---|---|---|
|
|
3c22eb71ab | |
|
|
dda8f8fe1f | |
|
|
50f504c2ff | |
|
|
9e992d8280 | |
|
|
2d1bca9cb4 | |
|
|
6f29948f8a | |
|
|
bb439a4d93 | |
|
|
a5b9dc1153 | |
|
|
1e0344ee34 | |
|
|
581a275bc0 | |
|
|
805ceca152 | |
|
|
2ee437e83e | |
|
|
7dfdd91798 | |
|
|
dac8450586 | |
|
|
8f50a68687 | |
|
|
f90b476bfd | |
|
|
136e5c826a | |
|
|
d0f2a72dda | |
|
|
a764213a77 | |
|
|
f8c6c538b7 | |
|
|
0930ea9c26 | |
|
|
db555bbf55 | |
|
|
d43dd04e9b | |
|
|
8dcab4da77 | |
|
|
470270118b | |
|
|
58dc110007 | |
|
|
1f285ea718 | |
|
|
19bed475ee | |
|
|
9ec7d8e8ae | |
|
|
a8d54699ff | |
|
|
0f32d6d73a | |
|
|
15f2ab3cde | |
|
|
fd3f738b9f | |
|
|
cee5acab1d | |
|
|
e04485c446 | |
|
|
696f275567 | |
|
|
9009ffdfb0 | |
|
|
b9b227e524 | |
|
|
bb6708e94b | |
|
|
c59a806c2c | |
|
|
58a96355f1 | |
|
|
2fea387d43 | |
|
|
c996805122 | |
|
|
f4a4082677 | |
|
|
3934e6a872 | |
|
|
f451add13c | |
|
|
41e36299b7 | |
|
|
8a8446bfec | |
|
|
844f052462 | |
|
|
cbc6242613 | |
|
|
ca6f160073 | |
|
|
cf18ecb6ff | |
|
|
0d5fb1b79a | |
|
|
79e1173dd3 | |
|
|
011dd08f08 | |
|
|
40b28be0ee | |
|
|
89d6a01b68 | |
|
|
a04e8edb6e | |
|
|
5f88b2ca65 | |
|
|
f099cf20d8 | |
|
|
bcf6f9d16f | |
|
|
dc9d7de60f | |
|
|
43c5f75055 | |
|
|
9abfbff654 | |
|
|
e6dc1972fb | |
|
|
5a5636c738 | |
|
|
cfb4110d82 | |
|
|
6244749545 | |
|
|
838c4cb7ae | |
|
|
c8fbe95bd1 | |
|
|
140fbc8122 | |
|
|
a2196d232b | |
|
|
0d3be0502c | |
|
|
874e9b3885 | |
|
|
458414636d | |
|
|
3728ed0e95 | |
|
|
0056e71b39 | |
|
|
f6bbfe1ba2 | |
|
|
b9c6bb2810 | |
|
|
5e9da52690 | |
|
|
db2ba331eb |
|
|
@ -1,3 +1,4 @@
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
|
!.env.example
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.worktrees
|
.worktrees
|
||||||
.superpowers
|
.superpowers
|
||||||
|
.ruff_cache
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
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)
|
||||||
|
|
||||||
21
Caddyfile
21
Caddyfile
|
|
@ -1,6 +1,23 @@
|
||||||
:80
|
:80
|
||||||
|
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
file_server
|
|
||||||
|
|
||||||
try_files {path} /index.html
|
@static path /assets/* /icons/* /manifest.webmanifest /service-worker.js
|
||||||
|
handle @static {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
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 "/"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,14 @@
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install --prefer-offline
|
||||||
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
|
||||||
|
|
@ -11,3 +17,4 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
|
||||||
|
--- 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
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/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
|
||||||
|
|
@ -3,6 +3,10 @@ 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:
|
||||||
|
|
@ -15,6 +19,15 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
create extension if not exists pgcrypto;
|
-- 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.
|
||||||
|
|
||||||
create or replace function public.get_delivery_invitation_by_token(p_token text)
|
-- Step 1: First restore the original function (run restore-rpc-original.sql if needed)
|
||||||
returns jsonb
|
-- Step 2: Then run this migration
|
||||||
language plpgsql
|
|
||||||
security definer
|
CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text)
|
||||||
set search_path = public, extensions
|
RETURNS jsonb
|
||||||
as $$
|
LANGUAGE plpgsql
|
||||||
declare
|
SECURITY DEFINER
|
||||||
|
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;
|
||||||
|
|
@ -18,260 +23,275 @@ 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_group.customer ->> 'name', ''),
|
NULLIF(v_invitation.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', ''),
|
NULLIF(v_group.customer_phone_normalized, ''),
|
||||||
nullif(v_invitation.customer_phone, '')
|
NULLIF(v_invitation.customer_phone, '')
|
||||||
);
|
);
|
||||||
select coalesce(
|
|
||||||
jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
|
|
||||||
'[]'::jsonb
|
|
||||||
)
|
|
||||||
into v_order_items
|
|
||||||
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;
|
|
||||||
|
|
||||||
return jsonb_build_object(
|
-- Build orderItems: use source_orders for real product lines if available,
|
||||||
'ok', true,
|
-- 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', onum, 'quantity', ''))
|
||||||
|
FROM unnest(v_group.order_numbers) AS onum
|
||||||
|
WHERE onum IS NOT NULL AND onum <> ''),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
END;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'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;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
create or replace function public.confirm_delivery_choice_by_token(
|
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;
|
||||||
|
|
||||||
|
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',
|
||||||
|
|
@ -283,66 +303,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,
|
||||||
|
|
@ -355,14 +375,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',
|
||||||
|
|
@ -373,17 +393,14 @@ 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.get_delivery_invitation_by_token(text) from public;
|
REVOKE ALL ON FUNCTION public.confirm_delivery_choice_by_token(text, date, text) FROM public;
|
||||||
grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated;
|
GRANT EXECUTE ON FUNCTION public.confirm_delivery_choice_by_token(text, date, 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;
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,15 @@
|
||||||
"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",
|
||||||
"react": "^18.3.1",
|
"playwright": "1.60.0",
|
||||||
"react-dom": "^18.3.1",
|
"react": "18.3.1",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-dom": "18.3.1",
|
||||||
"tailwind-merge": "^3.3.0"
|
"react-router-dom": "7.3.0",
|
||||||
|
"tailwind-merge": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|
@ -77,7 +78,6 @@
|
||||||
"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,83 +1426,78 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.99.0",
|
"version": "2.71.1",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
|
||||||
"integrity": "sha512-tHiIST/OEoLmWBE+3X69xRY5srJM/lL86KltmMlIfDo9ePJLo14vQQV9T4NF+P+MoGhCwQL1GTmk51zuAFMXKw==",
|
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/functions-js": {
|
"node_modules/@supabase/functions-js": {
|
||||||
"version": "2.99.0",
|
"version": "2.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
|
||||||
"integrity": "sha512-zA9oad6EqGwMLLu2LfP1bXbqKcJGiotAdbdTfZG7YS7619YZQAEgejj9mp+E5vglKE1yMWbKK+S1J3PbuUtgLg==",
|
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": ">=20.0.0"
|
"node": "4.x || >=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/postgrest-js": {
|
"node_modules/@supabase/postgrest-js": {
|
||||||
"version": "2.99.0",
|
"version": "1.19.4",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
|
||||||
"integrity": "sha512-8qfOMi2pu9y0IQhUAeFqjrvR49G4ELGevXCWV9qAHXFQ/h2FFh0I8PYjFQj4rHcHSq6hrpozDnS1vbQU8NAQ/A==",
|
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/realtime-js": {
|
"node_modules/@supabase/realtime-js": {
|
||||||
"version": "2.99.0",
|
"version": "2.11.15",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.15.tgz",
|
||||||
"integrity": "sha512-7nFTZhNeANR7FvEY6PfWLCfE8dHqcaJd9SuR7IPEZvBPG9K4uEHMivpjZx4NWRSU7Eji7ZbKy2LG+cJ48DhwHg==",
|
"integrity": "sha512-HQKRnwAqdVqJW/P9TjKVK+/ETpW4yQ8tyDPPtRMKOH4Uh3vQD74vmj353CYs8+YwVBKubeUOOEpI9CT8mT4obw==",
|
||||||
"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",
|
||||||
"tslib": "2.8.1",
|
"isows": "^1.0.7",
|
||||||
"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.99.0",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz",
|
||||||
"integrity": "sha512-mAEEbfsght5EEALejYrwAP9k8sFBGjfMZT8n4SyMXk2iYuWVeRMs1kA/uKg0uDMctWdZ0bL+L4jZzksUJpCjMA==",
|
"integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iceberg-js": "^0.8.1",
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/supabase-js": {
|
"node_modules/@supabase/supabase-js": {
|
||||||
"version": "2.99.0",
|
"version": "2.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.52.0.tgz",
|
||||||
"integrity": "sha512-SP9Sn9tsHDB7N4u2gT13rdeZJewE4xibAxasG7vOz+fYi92+XkMMbWNx0uGK53zKTnAnvTs16isRooyBy4sn5w==",
|
"integrity": "sha512-jbs3CV1f2+ge7sgBeEduboT9v/uGjF22v0yWi/5/XFn5tbM8MfWRccsMtsDwAwu24XK8H6wt2LJDiNnZLtx/bg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.99.0",
|
"@supabase/auth-js": "2.71.1",
|
||||||
"@supabase/functions-js": "2.99.0",
|
"@supabase/functions-js": "2.4.5",
|
||||||
"@supabase/postgrest-js": "2.99.0",
|
"@supabase/node-fetch": "2.6.15",
|
||||||
"@supabase/realtime-js": "2.99.0",
|
"@supabase/postgrest-js": "1.19.4",
|
||||||
"@supabase/storage-js": "2.99.0"
|
"@supabase/realtime-js": "2.11.15",
|
||||||
},
|
"@supabase/storage-js": "2.7.1"
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
|
|
@ -1561,6 +1556,12 @@
|
||||||
"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",
|
||||||
|
|
@ -1610,7 +1611,6 @@
|
||||||
"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,7 +1777,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -2151,7 +2150,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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",
|
||||||
|
|
@ -2889,7 +2887,6 @@
|
||||||
"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",
|
||||||
|
|
@ -3260,13 +3257,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.35.2",
|
"version": "12.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.2.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.7.4.tgz",
|
||||||
"integrity": "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==",
|
"integrity": "sha512-jX0bPsTmU0oPZTYz/dVyD0dmOyEOEJvdn0TaZBE5I8g2GvVnnQnW9f65cJnoVfUkY3WZWNXGXnPbVA9YnaIfVA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "^12.35.2",
|
"motion-dom": "^12.7.4",
|
||||||
"motion-utils": "^12.29.2",
|
"motion-utils": "^12.7.2",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -3569,15 +3566,6 @@
|
||||||
"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",
|
||||||
|
|
@ -4049,6 +4037,21 @@
|
||||||
"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",
|
||||||
|
|
@ -4073,7 +4076,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -4692,6 +4694,50 @@
|
||||||
"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",
|
||||||
|
|
@ -4722,7 +4768,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -4945,7 +4990,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -4958,7 +5002,6 @@
|
||||||
"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"
|
||||||
|
|
@ -4985,13 +5028,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.13.1",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz",
|
||||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
"integrity": "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw==",
|
||||||
"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"
|
||||||
|
|
@ -5007,12 +5052,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.13.1",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.3.0.tgz",
|
||||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
"integrity": "sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.13.1"
|
"react-router": "7.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|
@ -5657,9 +5702,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.5.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
|
||||||
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -5803,7 +5848,6 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5854,6 +5898,12 @@
|
||||||
"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",
|
||||||
|
|
@ -5867,6 +5917,12 @@
|
||||||
"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",
|
||||||
|
|
@ -6037,7 +6093,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6154,7 +6209,6 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -6248,6 +6302,22 @@
|
||||||
"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",
|
||||||
|
|
@ -6381,9 +6451,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|
|
||||||
20
package.json
20
package.json
|
|
@ -12,14 +12,16 @@
|
||||||
"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",
|
||||||
"react": "^18.3.1",
|
"playwright": "1.60.0",
|
||||||
"react-dom": "^18.3.1",
|
"react": "18.3.1",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-dom": "18.3.1",
|
||||||
"tailwind-merge": "^3.3.0"
|
"react-router-dom": "7.3.0",
|
||||||
|
"tailwind-merge": "3.3.0",
|
||||||
|
"recharts": "^2.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|
@ -36,4 +38,4 @@
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
"vitest": "^3.0.9"
|
"vitest": "^3.0.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "Школьное питание",
|
"name": "SuperSam Доставка",
|
||||||
"short_name": "Школьное питание",
|
"short_name": "SuperSam",
|
||||||
"description": "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера.",
|
"description": "Панель управления доставкой стройматериалов с уведомлениями.",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,125 @@
|
||||||
const STATIC_CACHE = "construction-delivery-static-v1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v1";
|
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
if (!isLocalhost) {
|
||||||
event.waitUntil(
|
const STATIC_CACHE = "construction-delivery-static-v4";
|
||||||
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL_URLS)).then(() => self.skipWaiting()),
|
const RUNTIME_CACHE = "construction-delivery-runtime-v4";
|
||||||
);
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL_URLS)).then(() => self.skipWaiting()),
|
||||||
.keys()
|
);
|
||||||
.then((keys) =>
|
});
|
||||||
Promise.all(
|
|
||||||
keys
|
|
||||||
.filter((key) => ![STATIC_CACHE, RUNTIME_CACHE].includes(key))
|
|
||||||
.map((key) => caches.delete(key)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then(() => self.clients.claim())
|
|
||||||
.then(async () => {
|
|
||||||
const clients = await self.clients.matchAll({ includeUncontrolled: true });
|
|
||||||
clients.forEach((client) => client.postMessage({ type: "PWA_OFFLINE_READY" }));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
if (event.request.method !== "GET") {
|
event.waitUntil(
|
||||||
return;
|
caches
|
||||||
}
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => ![STATIC_CACHE, RUNTIME_CACHE].includes(key))
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
.then(async () => {
|
||||||
|
const clients = await self.clients.matchAll({ includeUncontrolled: true });
|
||||||
|
clients.forEach((client) => client.postMessage({ type: "PWA_OFFLINE_READY" }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const requestUrl = new URL(event.request.url);
|
self.addEventListener("fetch", (event) => {
|
||||||
const isSameOrigin = requestUrl.origin === self.location.origin;
|
if (event.request.method !== "GET") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(event.request.url);
|
||||||
|
const isSameOrigin = requestUrl.origin === self.location.origin;
|
||||||
|
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cachedPage = await caches.match(event.request);
|
||||||
|
return cachedPage || caches.match("/index.html");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameOrigin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.request.mode === "navigate") {
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
.then((response) => {
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const responseClone = response.clone();
|
const responseClone = response.clone();
|
||||||
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
||||||
return response;
|
return response;
|
||||||
})
|
});
|
||||||
.catch(async () => {
|
}),
|
||||||
const cachedPage = await caches.match(event.request);
|
|
||||||
return cachedPage || caches.match("/index.html");
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return;
|
});
|
||||||
|
} 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: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSameOrigin) {
|
const title = data.title || "Уведомление";
|
||||||
return;
|
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.respondWith(
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
caches.match(event.request).then((cachedResponse) => {
|
});
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(event.request).then((response) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
if (!response || response.status !== 200) {
|
event.notification.close();
|
||||||
return response;
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const responseClone = response.clone();
|
return self.clients.openWindow(targetUrl);
|
||||||
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/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()
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
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;
|
||||||
|
|
@ -10,6 +10,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
@ -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 backdrop-blur",
|
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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") {
|
if (currentUser.role !== "admin" && currentUser.role !== "mega_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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,36 +18,6 @@ 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,
|
||||||
|
|
@ -56,9 +26,6 @@ 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) {
|
||||||
|
|
@ -78,27 +45,10 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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", () => {
|
||||||
|
|
@ -38,32 +39,6 @@ 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
|
||||||
|
|
@ -113,3 +88,31 @@ 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
|
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "./deliveryDateFormatting";
|
||||||
|
|
||||||
const groupSlotsByDate = (slots) => {
|
const groupSlotsByDate = (slots) => {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
|
|
@ -36,7 +36,19 @@ const groupSlotsByDate = (slots) => {
|
||||||
.sort(([a], [b]) => a.localeCompare(b));
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
};
|
};
|
||||||
|
|
||||||
export { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
|
const getDeliverySlotGroupHeading = (dateStr, referenceDate = new Date()) => {
|
||||||
|
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,
|
||||||
|
|
@ -60,10 +72,7 @@ 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">
|
||||||
<div className="space-y-1">
|
<h4 className="font-medium">{getDeliverySlotGroupHeading(date, referenceDate)}</h4>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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-4 flex items-end justify-between gap-4">
|
<div className="mt-3">
|
||||||
<span className="text-3xl font-semibold">{value}</span>
|
<span className="text-3xl font-semibold">{value}</span>
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">{hint}</span>
|
{hint && <p className="mt-1 text-xs text-[var(--color-text-muted)]">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,42 @@
|
||||||
import React from "react";
|
import React, { useState } 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: "" };
|
||||||
|
|
@ -29,6 +61,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -39,8 +73,42 @@ 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>
|
||||||
|
|
@ -110,22 +178,28 @@ export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{availableTransitions.length ? (
|
{actionButtons.length > 0 && (
|
||||||
<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">
|
||||||
{availableTransitions.map((status) => (
|
{actionButtons.map((btn) => (
|
||||||
<Button
|
<Button
|
||||||
key={status}
|
key={btn.value}
|
||||||
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
variant={btn.value === "Проблема доставки" ? "ghost" : "secondary"}
|
||||||
onClick={() => onStatusChange?.(status)}
|
onClick={() => {
|
||||||
|
if (btn.value === "Проблема доставки") {
|
||||||
|
setShowProblemModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onStatusChange?.(btn.value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{status}
|
{btn.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,59 +1,198 @@
|
||||||
|
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: getOrderGroupDeliveryStatusLabel(status),
|
label: status === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(status),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
const pluralGroups = (n) => {
|
||||||
const [filters, setFilters] = React.useState({
|
if (n === 1) return "группа";
|
||||||
dateFrom: "",
|
if (n >= 2 && n < 5) return "группы";
|
||||||
dateTo: "",
|
return "групп";
|
||||||
deliveryHalfDay: "all",
|
};
|
||||||
deliveryStatus: "all",
|
|
||||||
|
/** 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;
|
||||||
|
};
|
||||||
|
|
||||||
const agreedOrderGroups = React.useMemo(
|
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
|
||||||
() => orderGroups.filter((group) => isOrderGroupVisibleToDriver(group)),
|
const [filters, setFilters] = React.useState({
|
||||||
[orderGroups],
|
selectedDate: "",
|
||||||
|
deliveryStatus: "all",
|
||||||
|
selectedCity: "",
|
||||||
|
});
|
||||||
|
const [collapsedDates, setCollapsedDates] = React.useState({});
|
||||||
|
|
||||||
|
const toggleDate = (date) => {
|
||||||
|
setCollapsedDates((prev) => ({ ...prev, [date]: !prev[date] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const driverOrderGroups = React.useMemo(
|
||||||
|
() => orderGroups.filter((group) => {
|
||||||
|
const isVisible = isOrderGroupVisibleToDriver(group);
|
||||||
|
const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id;
|
||||||
|
return isVisible && isAssignedToMe;
|
||||||
|
}),
|
||||||
|
[orderGroups, currentUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredOrderGroups = React.useMemo(
|
const dateDeliveryMap = React.useMemo(() => {
|
||||||
() =>
|
const map = new Map();
|
||||||
filterOrderGroups(agreedOrderGroups, {
|
driverOrderGroups.forEach((group) => {
|
||||||
dateFrom: filters.dateFrom,
|
const date = group.deliveryDate;
|
||||||
dateTo: filters.dateTo,
|
if (date) {
|
||||||
deliveryHalfDay: filters.deliveryHalfDay,
|
map.set(date, (map.get(date) || 0) + 1);
|
||||||
deliveryStatus: filters.deliveryStatus,
|
}
|
||||||
}),
|
});
|
||||||
[agreedOrderGroups, filters.dateFrom, filters.dateTo, filters.deliveryHalfDay, filters.deliveryStatus],
|
return map;
|
||||||
);
|
}, [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">
|
||||||
|
|
@ -65,49 +204,22 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
||||||
<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-[repeat(4,minmax(0,1fr))]">
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_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.dateFrom}
|
value={filters.selectedDate}
|
||||||
onChange={(event) => setFilters((current) => ({ ...current, dateFrom: event.target.value }))}
|
onChange={(event) => setFilters((current) => ({ ...current, selectedDate: 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)]">
|
||||||
Статус
|
Статус
|
||||||
|
|
@ -126,63 +238,187 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
||||||
</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) => {
|
||||||
<Panel key={group.date} className="space-y-4 p-5">
|
const isCollapsed = collapsedDates[group.date];
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
const statusCounts = dateStatusSummary[group.date] || [];
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-semibold capitalize">
|
|
||||||
{new Date(`${group.date}T12:00:00`).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">{group.date}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
// Group items by delivery status within each date
|
||||||
{group.items.map((item) => (
|
const statusBuckets = new Map();
|
||||||
<button
|
for (const item of group.items) {
|
||||||
key={item.id}
|
const s = item.deliveryStatus || item.delivery_status || "unknown";
|
||||||
type="button"
|
const label = s === "driver_assigned" ? "Назначено вам" : getOrderGroupDeliveryStatusLabel(s);
|
||||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
const tone = getOrderGroupDeliveryStatusTone(s);
|
||||||
onClick={() => onOpenOrder?.(item.id)}
|
if (!statusBuckets.has(s)) {
|
||||||
>
|
statusBuckets.set(s, { label, tone, items: [] });
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
}
|
||||||
<div>
|
statusBuckets.get(s).items.push(item);
|
||||||
<div className="font-medium text-[var(--color-text)]">
|
}
|
||||||
{item.displayTitle || item.customerName || item.groupKey}
|
|
||||||
|
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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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">
|
||||||
|
{parseGroupDate(group.date)?.toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
weekday: "long",
|
||||||
|
}) || "Без даты"}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||||
|
{statusCounts.map(({ status, label, tone, count }) => (
|
||||||
|
<Badge key={status} tone={tone}>{count} {label}</Badge>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<div className="grid gap-3">
|
||||||
{item.customerDate} · {item.customerPhone}
|
{items.map((item) => (
|
||||||
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(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>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone={getOrderGroupDeliveryStatusTone(item.deliveryStatus || item.delivery_status)}>
|
))}
|
||||||
{getOrderGroupDeliveryStatusLabel(item.deliveryStatus || item.delivery_status)}
|
</div>
|
||||||
</Badge>
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
|
);
|
||||||
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
|
})
|
||||||
<div>{item.orderNumbers?.[0] || "Номера не указаны"}</div>
|
|
||||||
<div>
|
|
||||||
{item.readyCount || 0}/{item.ordersCount || 0} готово
|
|
||||||
</div>
|
|
||||||
<div>{item.smsSentAt ? "SMS отправлено" : "SMS не отправлено"}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<Panel className="p-6">
|
<Panel className="p-6">
|
||||||
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -18,6 +18,7 @@ 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",
|
||||||
|
|
@ -47,6 +48,7 @@ describe("DriverDeliveryPlanner", () => {
|
||||||
<DriverDeliveryPlanner
|
<DriverDeliveryPlanner
|
||||||
orderGroups={orderGroups}
|
orderGroups={orderGroups}
|
||||||
onOpenOrder={() => {}}
|
onOpenOrder={() => {}}
|
||||||
|
currentUser={{ id: "driver-1" }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -55,8 +57,7 @@ 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("Канбан");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,57 +1,77 @@
|
||||||
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";
|
||||||
|
|
||||||
const BUCKET_ICONS = {
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS }) => {
|
||||||
ready_to_launch: "\u2713",
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||||
sms_sent: "\u2709",
|
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
|
||||||
manual_work: "\u26A0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderOrderNumbers = (group) => {
|
const cities = React.useMemo(() => {
|
||||||
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
|
const set = new Set();
|
||||||
return <span>Номера не указаны</span>;
|
for (const g of orderGroups) {
|
||||||
}
|
if (g.city) set.add(g.city);
|
||||||
|
}
|
||||||
return (
|
return [...set].sort();
|
||||||
<div className="flex flex-wrap gap-2">
|
}, [orderGroups]);
|
||||||
{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(
|
|
||||||
() => buildOrderGroupBuckets(filteredGroups),
|
|
||||||
[filteredGroups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS);
|
const statusGroups = React.useMemo(() => {
|
||||||
const buckets = deliveryGroupBuckets || {};
|
const map = new Map();
|
||||||
|
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 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>
|
||||||
|
);
|
||||||
|
|
||||||
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">
|
||||||
|
|
@ -65,74 +85,105 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
|
||||||
<OrderFilters
|
<OrderFilters
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
statusOptions={ORDER_GROUP_DISPLAY_STATUS_OPTIONS}
|
statusOptions={statusOptions}
|
||||||
|
cities={cities}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{!totalGroups ? (
|
{!totalGroups ? (
|
||||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-4">
|
||||||
{bucketKeys.map((bucketKey) => {
|
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
||||||
const groups = buckets[bucketKey] || [];
|
const idxA = FUNNEL_ORDER.indexOf(a);
|
||||||
const label = ORDER_GROUP_BUCKET_LABELS[bucketKey];
|
const idxB = FUNNEL_ORDER.indexOf(b);
|
||||||
const icon = BUCKET_ICONS[bucketKey];
|
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
||||||
|
if (idxA === -1) return 1;
|
||||||
if (!groups.length) {
|
if (idxB === -1) return -1;
|
||||||
return (
|
return idxA - idxB;
|
||||||
<Panel key={bucketKey} className="p-5 opacity-50">
|
}).map(([statusValue, { label, groups }]) => {
|
||||||
<div className="flex items-center gap-2">
|
const isCollapsed = collapsedSections.has(statusValue);
|
||||||
<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 (
|
||||||
<div key={bucketKey} className="space-y-3">
|
<Panel key={statusValue} className="overflow-hidden p-0">
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
<span className="text-lg">{icon}</span>
|
type="button"
|
||||||
<h3 className="font-semibold">{label}</h3>
|
className="flex w-full items-center justify-between px-5 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||||
<Badge tone={bucketKey === "sms_sent" ? "accent" : "neutral"}>{groups.length}</Badge>
|
onClick={() => {
|
||||||
</div>
|
setCollapsedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
{groups.map((group) => (
|
if (next.has(statusValue)) {
|
||||||
<button
|
next.delete(statusValue);
|
||||||
key={group.id}
|
} else {
|
||||||
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"
|
next.add(statusValue);
|
||||||
onClick={() => {
|
|
||||||
if (onSelectSet) {
|
|
||||||
onSelectSet(group);
|
|
||||||
}
|
}
|
||||||
}}
|
return next;
|
||||||
type="button"
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{label}</h3>
|
||||||
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
||||||
|
</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}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
</svg>
|
||||||
<div className="break-words text-base font-semibold leading-tight !text-[var(--color-text)] sm:text-lg">
|
</button>
|
||||||
{group.displayTitle || group.customerName || group.groupKey}
|
|
||||||
</div>
|
{!isCollapsed && (
|
||||||
<Badge className="self-start" tone={getOrderGroupStatusTone(group)}>
|
<div className="overflow-x-auto">
|
||||||
{getOrderGroupDisplayStatusLabel(group)}
|
<table className="min-w-full border-collapse border-t border-[var(--color-border)]">
|
||||||
</Badge>
|
<TableHeader />
|
||||||
</div>
|
<tbody>
|
||||||
<div className="text-sm leading-6 text-[var(--color-text-muted)]">
|
{groups.map((group) => (
|
||||||
{group.customerDate || "—"} · {group.customerPhone || "—"} · {group.ordersCount || 0}{" "}
|
<tr
|
||||||
{group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}
|
key={group.id}
|
||||||
</div>
|
className="cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]"
|
||||||
<div>{renderOrderNumbers(group)}</div>
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
</div>
|
>
|
||||||
</button>
|
<td className="px-4 py-2.5">
|
||||||
))}
|
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||||
</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">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-sm text-[var(--color-text-muted)] hidden md:table-cell">
|
||||||
|
{formatDateTime(group.updatedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@ 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,
|
||||||
|
|
@ -37,6 +39,57 @@ 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;
|
||||||
|
|
@ -98,6 +151,14 @@ 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);
|
||||||
|
|
||||||
|
|
@ -185,16 +246,209 @@ 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);
|
||||||
|
|
@ -216,6 +470,11 @@ 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();
|
||||||
|
|
@ -235,12 +494,18 @@ export const OrderDetailPanel = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDeliveryAgreed = (order.deliveryStatus || order.delivery_status) === "agreed";
|
const isDeliveryAgreed = ["agreed", "driver_assigned", "loaded", "on_route", "delivered"].includes(order.deliveryStatus || order.delivery_status);
|
||||||
|
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("Укажите дату и половину дня доставки.");
|
||||||
|
|
@ -270,6 +535,30 @@ 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">
|
||||||
|
|
@ -288,61 +577,76 @@ 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)] p-4 md:grid-cols-2">
|
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-3">
|
||||||
<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-xl font-semibold">{formatDeliveryDateDisplay(order.deliveryDate)}</p>
|
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{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-xl font-semibold">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
|
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{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-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-x-4 gap-y-2 grid-cols-2 md: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="mt-1 font-medium">{renderValue(order.groupKey)}</p>
|
<p className="font-medium !text-[var(--color-text)]">{renderValue(order.orderNumberSummary)}</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="mt-1 font-medium">{renderValue(order.customerName)}</p>
|
<p className="font-medium !text-[var(--color-text)]">{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="mt-1 font-medium">{renderValue(order.customerPhone)}</p>
|
<p className="font-medium !text-[var(--color-text)]">{renderValue(order.customerDate)}</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="mt-1 font-medium">{order.ordersCount ?? 0}</p>
|
<p className="font-medium !text-[var(--color-text)]">{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="mt-1 font-medium">{order.readyCount ?? 0}</p>
|
<p className="font-medium !text-[var(--color-text)]">{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="mt-1 font-medium">{order.notReadyCount ?? 0}</p>
|
<p className="font-medium !text-[var(--color-text)]">{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="mt-1 font-medium">{formatDateTime(order.updatedAt)}</p>
|
<p className="font-medium !text-[var(--color-text)]">{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="mt-1 font-medium">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}</p>
|
<p className="font-medium !text-[var(--color-text)]">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
@ -357,19 +661,31 @@ export const OrderDetailPanel = ({
|
||||||
: "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."}
|
: "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isDeliveryAgreed ? (
|
{isDeliveryAgreed && !isEditingDate ? (
|
||||||
<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="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-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>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
|
<div>
|
||||||
Доставка согласована
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
|
||||||
</p>
|
Доставка согласована
|
||||||
<p className="mt-1 text-lg font-semibold">
|
</p>
|
||||||
{agreedDeliveryLabel || "Дата и время сохранены"}
|
<p className="mt-1 text-lg font-semibold">
|
||||||
</p>
|
{agreedDeliveryLabel || "Дата и время сохранены"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="accent">Согласовано</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="accent">Согласовано</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
{canEditDelivery ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => { setIsEditingDate(true); setFormMessage(""); }}
|
||||||
|
disabled={isSavingDeliveryChoice}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Изменить дату доставки
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</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">
|
||||||
|
|
@ -378,7 +694,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>
|
||||||
|
|
@ -403,7 +719,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))}
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
|
|
@ -411,7 +727,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))}
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
|
|
@ -447,10 +763,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={() => {
|
||||||
|
|
@ -489,8 +805,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);
|
||||||
|
|
@ -516,32 +832,278 @@ 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">
|
||||||
<strong>Дополнительные данные</strong>
|
<CollapsibleOrderComposition order={order} />
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
</Panel>
|
||||||
|
{userRole !== "driver" ? (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<strong>Дополнительные данные</strong>
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
|
||||||
|
<p className="mt-1 font-medium !text-[var(--color-text)]">Нет</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Ручное согласование выполнено</p>
|
||||||
<p className="mt-1 font-medium">{order.smsSentAt ? "Да" : "Нет"}</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">{formatDateTime(order.createdFromExchangeAt)}</p>
|
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.createdFromExchangeAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{order.sourceKey ? (
|
</div>
|
||||||
<div>
|
</Panel>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Ключ источника</p>
|
) : null}
|
||||||
<p className="mt-1 font-medium">{order.sourceKey}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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");
|
const getManagerOptions = (users) => getUsers(users).filter((user) => user.role === "manager" || user.role === "admin" || user.role === "mega_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";
|
const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin" || currentUser.role === "mega_admin";
|
||||||
const managerOptions = getManagerOptions(users);
|
const managerOptions = getManagerOptions(users);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,24 @@
|
||||||
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 = [] }) => {
|
export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = [] }) => {
|
||||||
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) {
|
if (!isStatusOpen) return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerDown = (event) => {
|
const handlePointerDown = (event) => {
|
||||||
if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) {
|
if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) setIsStatusOpen(false);
|
||||||
setIsStatusOpen(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") setIsStatusOpen(false);
|
||||||
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);
|
||||||
|
|
@ -39,82 +29,118 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
|
||||||
setFilters((current) => ({ ...current, [key]: value }));
|
setFilters((current) => ({ ...current, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeChips = [statusValue !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean);
|
const hasDateFilter = filters.dateFrom || filters.dateTo;
|
||||||
|
|
||||||
|
const clearDateFilter = () => {
|
||||||
|
setFilters((current) => ({ ...current, dateFrom: "", dateTo: "" }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.6fr)_minmax(12rem,0.7fr)] md:items-end">
|
<div className="flex flex-col gap-3">
|
||||||
<Input
|
{/* Row 1: Status + City + Search */}
|
||||||
className="h-[46px] py-0"
|
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,0.5fr)_minmax(0,1.6fr)] md:items-end">
|
||||||
placeholder="Поиск по группе, клиенту или телефону"
|
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||||
value={filters.query}
|
<button
|
||||||
onChange={(event) => updateFilter("query", event.target.value)}
|
type="button"
|
||||||
/>
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isStatusOpen}
|
||||||
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
className={[
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||||
Статус
|
isStatusOpen
|
||||||
</span>
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
<button
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||||
type="button"
|
].join(" ")}
|
||||||
aria-haspopup="listbox"
|
onClick={() => setIsStatusOpen((current) => !current)}
|
||||||
aria-expanded={isStatusOpen}
|
|
||||||
className={[
|
|
||||||
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
|
||||||
isStatusOpen
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => setIsStatusOpen((current) => !current)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
|
|
||||||
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">
|
|
||||||
▾
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isStatusOpen ? (
|
|
||||||
<div
|
|
||||||
role="listbox"
|
|
||||||
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft"
|
|
||||||
>
|
>
|
||||||
{statusOptions.map((option) => {
|
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
|
||||||
const isSelected = option.value === statusValue;
|
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
return (
|
{isStatusOpen ? (
|
||||||
<button
|
<div
|
||||||
key={option.value}
|
role="listbox"
|
||||||
type="button"
|
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"
|
||||||
role="option"
|
>
|
||||||
aria-selected={isSelected}
|
{statusOptions.map((option) => {
|
||||||
className={[
|
const isSelected = option.value === statusValue;
|
||||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition",
|
return (
|
||||||
isSelected
|
<button
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
key={option.value}
|
||||||
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
type="button"
|
||||||
].join(" ")}
|
role="option"
|
||||||
onClick={() => {
|
aria-selected={isSelected}
|
||||||
updateFilter("displayStatus", option.value);
|
className={[
|
||||||
setIsStatusOpen(false);
|
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition",
|
||||||
}}
|
isSelected
|
||||||
>
|
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||||
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
].join(" ")}
|
||||||
</button>
|
onClick={() => {
|
||||||
);
|
updateFilter("displayStatus", option.value);
|
||||||
})}
|
setIsStatusOpen(false);
|
||||||
</div>
|
}}
|
||||||
) : null}
|
>
|
||||||
|
<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}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cities.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={filters.city || ""}
|
||||||
|
onChange={(e) => updateFilter("city", e.target.value)}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{activeChips.length ? (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{activeChips.map((chip) => (
|
|
||||||
<Badge key={chip.key}>{chip.label}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -9,9 +9,16 @@ 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 readyCountLabel = `${group.readyCount || 0} готовы`;
|
const parts = [orderCountLabel];
|
||||||
|
if (group.deliveryDate) {
|
||||||
|
const datePart = group.deliveryTime ? `${group.deliveryDate} · ${group.deliveryTime}` : group.deliveryDate;
|
||||||
|
parts.push(datePart);
|
||||||
|
}
|
||||||
|
if (group.assignedDriverName) {
|
||||||
|
parts.push(group.assignedDriverName);
|
||||||
|
}
|
||||||
|
|
||||||
return `${orderCountLabel} · ${readyCountLabel}`;
|
return parts.join(" · ");
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOrderNumbers = (group) => {
|
const renderOrderNumbers = (group) => {
|
||||||
|
|
@ -29,6 +36,7 @@ export const OrdersTable = ({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
|
cities = [],
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Panel className="p-0">
|
<Panel className="p-0">
|
||||||
|
|
@ -44,15 +52,15 @@ export const OrdersTable = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters && setFilters ? (
|
{filters && setFilters ? (
|
||||||
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} />
|
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
|
||||||
) : 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 ? (
|
||||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{orderGroups.map((group) => (
|
{orderGroups.map((group) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -100,7 +108,8 @@ 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>
|
||||||
|
|
@ -130,8 +139,15 @@ 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 text-[var(--color-text-muted)]">
|
<td className="px-5 py-4 text-sm">
|
||||||
{group.readyCount || 0}/{group.ordersCount || 0}
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</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)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const CRIMEAN_CITIES = [
|
||||||
|
"Севастополь","Ялта","Алушта","Евпатория","Саки","Феодосия",
|
||||||
|
"Керчь","Симферополь","Бахчисарай","Судак","Белогорск",
|
||||||
|
"Красноперекопск","Джанкой","Щёлкино","Гаспра","Гурзуф",
|
||||||
|
"Кореиз","Ливадия","Массандра","Ореанда","Симеиз",
|
||||||
|
"Форос","Партенит","Мисхор","Отрадное","Санаторное",
|
||||||
|
"Васильевка","Куйбышево","Инкерман","Балаклава",
|
||||||
|
"Утёс","Резниково","Заветное","Хмельницкое","Мирновка",
|
||||||
|
"Новосёловка","Гвардейское","Красногвардейское",
|
||||||
|
"Раздольное","Черноморское","Ленино","Советский",
|
||||||
|
"Нижнегорский","Первомайское","Октябрьское",
|
||||||
|
];
|
||||||
|
|
@ -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 || "Комментарий не задан.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export const ROLE_LABELS = {
|
||||||
logistician: "Логист",
|
logistician: "Логист",
|
||||||
driver: "Водитель-экспедитор",
|
driver: "Водитель-экспедитор",
|
||||||
admin: "Администратор",
|
admin: "Администратор",
|
||||||
|
mega_admin: "Суперадмин",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROLE_PERMISSIONS = {
|
export const ROLE_PERMISSIONS = {
|
||||||
|
|
@ -32,4 +33,10 @@ export const ROLE_PERMISSIONS = {
|
||||||
"Управление пользователями и ролями",
|
"Управление пользователями и ролями",
|
||||||
"Логи, ошибки и история действий",
|
"Логи, ошибки и история действий",
|
||||||
],
|
],
|
||||||
};
|
mega_admin: [
|
||||||
|
"Полный доступ ко всем разделам",
|
||||||
|
"Управление пользователями и ролями",
|
||||||
|
"Аналитика и автоматизация",
|
||||||
|
"Логи ошибок",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
import React, { createContext, useContext, useEffect, useRef, 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 не найден в системе. Обратитесь к администратору.";
|
||||||
|
|
||||||
|
|
@ -14,6 +32,9 @@ 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 = [
|
||||||
|
|
@ -78,21 +99,59 @@ 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 || "manager",
|
role: userMetadata.role || appMetadata.role || null,
|
||||||
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 ? JSON.parse(stored) : null;
|
return stored ? decodeLocalAuth(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;
|
||||||
|
|
@ -104,11 +163,29 @@ export const AuthProvider = ({ children }) => {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
|
window.__supersam_user_id__ = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUser = mapSessionUserToAuthUser(session.user);
|
// Block session restore if user explicitly signed out (ref or sessionStorage flag)
|
||||||
setUser(nextUser);
|
if (signedOutRef.current || isSignedOut()) {
|
||||||
|
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("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -116,12 +193,27 @@ 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) {
|
||||||
setUser(mapSessionUserToAuthUser(data.session.user));
|
const baseUser = 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -129,10 +221,10 @@ export const AuthProvider = ({ children }) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && !hasSupabaseConfig) {
|
if (user && isDemoMode) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
const encoded = encodeLocalAuth(user); if (encoded) localStorage.setItem(STORAGE_KEY, encoded);
|
||||||
}
|
}
|
||||||
if (!user && !hasSupabaseConfig) {
|
if (!user && isDemoMode) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
@ -148,7 +240,17 @@ export const AuthProvider = ({ children }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || data?.ok === false) {
|
if (error || data?.ok === false) {
|
||||||
throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
|
let edgeErrorMessage = data?.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");
|
||||||
|
|
@ -159,6 +261,7 @@ 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);
|
||||||
|
|
@ -174,9 +277,23 @@ export const AuthProvider = ({ children }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || data?.ok === false) {
|
if (error || data?.ok === false) {
|
||||||
throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
|
let edgeErrorMessage = data?.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({
|
||||||
access_token: data.session.access_token,
|
access_token: data.session.access_token,
|
||||||
|
|
@ -187,7 +304,15 @@ export const AuthProvider = ({ children }) => {
|
||||||
throw normalizeOtpError(sessionError);
|
throw normalizeOtpError(sessionError);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(mapSessionUserToAuthUser(sessionData.session?.user || data.session.user));
|
const baseUser = 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));
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +331,7 @@ 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);
|
||||||
|
|
@ -213,22 +339,35 @@ 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) {
|
||||||
await supabase.auth.signOut();
|
try {
|
||||||
|
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: !hasSupabaseConfig,
|
isDemoMode,
|
||||||
requestOtp,
|
requestOtp,
|
||||||
verifyOtp,
|
verifyOtp,
|
||||||
signOut,
|
signOut,
|
||||||
|
|
@ -247,4 +386,4 @@ export const useAuth = () => {
|
||||||
throw new Error("useAuth must be used within AuthProvider");
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
@ -816,6 +816,8 @@ 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,
|
||||||
|
|
@ -896,6 +898,9 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { demoOrderGroups } from "../data/mockAppData";
|
import { assignDriverToOrderGroup, fetchOrderGroups, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
|
||||||
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) {
|
||||||
|
|
@ -28,17 +25,13 @@ 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(() =>
|
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
||||||
hasSupabaseConfig ? null : demoOrderGroups[0]?.id ?? null,
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
);
|
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -46,12 +39,7 @@ export const useOrderGroups = () => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const loadLiveData = async () => {
|
const loadLiveData = async () => {
|
||||||
if (!hasSupabaseConfig) {
|
/* Demo mode removed — always use Supabase */
|
||||||
setOrderGroups(cloneLiveGroups(demoOrderGroups));
|
|
||||||
setIsLoading(false);
|
|
||||||
setLoadError("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
|
|
@ -91,7 +79,22 @@ export const useOrderGroups = () => {
|
||||||
}
|
}
|
||||||
}, [orderGroups, selectedOrderGroupId]);
|
}, [orderGroups, selectedOrderGroupId]);
|
||||||
|
|
||||||
const statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS;
|
const statusCounts = React.useMemo(() => {
|
||||||
|
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),
|
||||||
|
|
@ -116,25 +119,7 @@ export const useOrderGroups = () => {
|
||||||
setIsSavingDeliveryChoice(true);
|
setIsSavingDeliveryChoice(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasSupabaseConfig) {
|
/* Demo mode removed */
|
||||||
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,
|
||||||
|
|
@ -163,6 +148,58 @@ 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,
|
||||||
|
|
@ -177,6 +214,8 @@ export const useOrderGroups = () => {
|
||||||
orderGroupsByDate,
|
orderGroupsByDate,
|
||||||
deliveryGroupBuckets,
|
deliveryGroupBuckets,
|
||||||
saveManualDeliveryChoice,
|
saveManualDeliveryChoice,
|
||||||
|
assignDriver,
|
||||||
|
changeDeliveryStatus,
|
||||||
isSavingDeliveryChoice,
|
isSavingDeliveryChoice,
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,15 @@ 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,
|
||||||
|
|
@ -14,13 +20,33 @@ 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)]">
|
||||||
|
|
@ -60,7 +86,9 @@ export const AppShell = ({
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
|
{/* Main content area */}
|
||||||
|
<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">
|
||||||
|
|
@ -71,15 +99,23 @@ 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.name} · {ROLE_LABELS[user.role] || 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}>
|
||||||
Выйти
|
Выйти
|
||||||
|
|
@ -88,6 +124,33 @@ 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>
|
||||||
|
|
@ -102,15 +165,23 @@ 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]}</div>
|
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role] || 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>
|
||||||
|
|
@ -119,31 +190,6 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
17
src/main.jsx
17
src/main.jsx
|
|
@ -4,17 +4,20 @@ 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} />
|
||||||
</AuthProvider>
|
</ErrorBoundary>
|
||||||
</ThemeProvider>
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|
@ -2,6 +2,7 @@ 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";
|
||||||
|
|
@ -305,15 +306,19 @@ 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">
|
||||||
<Panel className="space-y-3 p-5 sm:p-6">
|
{!isChoiceSaved ? (
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
|
<Panel className="space-y-3 p-5 sm:p-6">
|
||||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
|
||||||
{heroDescription ? (
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
{heroDescription ? (
|
||||||
{heroDescription}
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
</p>
|
{heroDescription}
|
||||||
) : null}
|
</p>
|
||||||
</Panel>
|
) : null}
|
||||||
|
</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">
|
||||||
|
|
@ -342,7 +347,7 @@ export const ClientDeliveryPage = () => {
|
||||||
selectedSlot={effectiveSelectedSlot}
|
selectedSlot={effectiveSelectedSlot}
|
||||||
onConfirmChoice={handleSaveChoice}
|
onConfirmChoice={handleSaveChoice}
|
||||||
/>
|
/>
|
||||||
) : !isChoiceSaved ? (
|
) : !isActiveState && !isChoiceSaved ? (
|
||||||
<DeliveryStateNotice state={invitationState} />
|
<DeliveryStateNotice state={invitationState} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,79 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate, useNavigate, useSearchParams } 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 { Button } from "../components/UI/Button";
|
import { AdminDashboard } from "../components/admin/AdminDashboard";
|
||||||
import { Modal } from "../components/UI/Modal";
|
import UserManagementPanel from "../components/admin/UserManagementPanel";
|
||||||
|
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 = {
|
||||||
manager: {
|
mega_admin: { key: "analytics", label: "Аналитика" },
|
||||||
key: "orders",
|
admin: { key: "analytics", label: "Аналитика", description: "Статистика доставки." },
|
||||||
label: "Группы",
|
manager: { key: "orders", label: "Группы", description: "Реестр групп доставки, поиск и просмотр карточки." },
|
||||||
description: "Реестр групп доставки, поиск и просмотр карточки.",
|
logistician: { key: "logistics", label: "Логистика", 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);
|
|
||||||
const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false);
|
// Active section from URL, fallback to role default
|
||||||
|
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,
|
||||||
|
|
@ -49,130 +81,111 @@ export const DashboardPage = () => {
|
||||||
statusOptions,
|
statusOptions,
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
saveManualDeliveryChoice,
|
|
||||||
isSavingDeliveryChoice,
|
|
||||||
} = useOrderGroups();
|
} = useOrderGroups();
|
||||||
|
|
||||||
React.useEffect(() => {
|
const cities = React.useMemo(() => {
|
||||||
setActiveSection(section.key);
|
const set = new Set();
|
||||||
}, [section.key]);
|
for (const g of allOrderGroups) {
|
||||||
|
if (g.city) set.add(g.city);
|
||||||
|
}
|
||||||
|
return [...set].sort();
|
||||||
|
}, [allOrderGroups]);
|
||||||
|
|
||||||
const openGroupModal = React.useCallback((groupId) => {
|
const openGroupPage = React.useCallback((groupId) => {
|
||||||
setSelectedOrderGroupId(groupId);
|
navigate("/dashboard/group/" + groupId);
|
||||||
setIsGroupModalOpen(true);
|
}, [navigate]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = isMegaAdmin
|
||||||
{
|
? MEGA_ADMIN_NAV
|
||||||
key: section.key,
|
: userRole === "admin"
|
||||||
label: section.label,
|
? [
|
||||||
description: section.description,
|
{ key: "analytics", label: "Аналитика", description: "Статистика доставки.", badge: null },
|
||||||
badge: String(allOrderGroups.length || orderGroups.length || 0),
|
{ key: "orders", label: "Группы", description: "Реестр групп доставки.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
},
|
{ key: "users", label: "Пользователи", description: "Управление пользователями.", badge: null },
|
||||||
];
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из карточки.", badge: null },
|
||||||
const guideSectionMeta = {
|
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||||
key: "guide",
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
label: "Справка",
|
]
|
||||||
description: "Карта продукта, роли, сценарии и частые вопросы.",
|
: userRole === "logistician"
|
||||||
};
|
? [
|
||||||
const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
|
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
const isGuideOpen = activeSection === "guide";
|
]
|
||||||
|
: [
|
||||||
|
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeSectionMeta = navItems.find((n) => n.key === activeSection) || navItems[0];
|
||||||
|
const isGuideOpen = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderLogisticsWorkspace = () => (
|
|
||||||
<div className="space-y-6 xl:space-y-8">
|
|
||||||
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupModal} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderDriverWorkspace = () => (
|
|
||||||
<div className="space-y-6 xl:space-y-8">
|
|
||||||
<DriverDeliveryPlanner
|
|
||||||
orderGroups={allOrderGroups}
|
|
||||||
onOpenOrder={openGroupModal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderActiveSection = () => {
|
const renderActiveSection = () => {
|
||||||
if (activeSection === "guide") {
|
|
||||||
return <ProductGuidePanel />;
|
if (activeSection === "analytics") return <div className="space-y-6 xl:space-y-8"><AdminDashboard /></div>;
|
||||||
}
|
if (activeSection === "users") return <div className="space-y-6 xl:space-y-8"><UserManagementPanel /></div>;
|
||||||
|
if (activeSection === "stop_words") return <div className="space-y-6 xl:space-y-8"><StopWordsPanel /></div>;
|
||||||
|
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
||||||
|
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
|
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
return renderDriverWorkspace();
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<DriverDeliveryPlanner orderGroups={allOrderGroups} onOpenOrder={openGroupPage} currentUser={user} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userRole === "logistician") {
|
if (userRole === "logistician") {
|
||||||
return renderLogisticsWorkspace();
|
if (activeSection === "orders") {
|
||||||
|
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">
|
||||||
|
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
return renderManagerWorkspace();
|
<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}
|
||||||
onOpenGuide={() => setActiveSection((current) => (current === "guide" ? section.key : "guide"))}
|
onInstallApp={onInstallApp}
|
||||||
isGuideOpen={isGuideOpen}
|
isInstalled={isInstalled}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -44,6 +44,7 @@ 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",
|
||||||
|
|
@ -76,6 +77,7 @@ const mockOrderGroupsState = {
|
||||||
loadError: "",
|
loadError: "",
|
||||||
saveManualDeliveryChoice: vi.fn(),
|
saveManualDeliveryChoice: vi.fn(),
|
||||||
isSavingDeliveryChoice: false,
|
isSavingDeliveryChoice: false,
|
||||||
|
assignDriver: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("DashboardPage", () => {
|
describe("DashboardPage", () => {
|
||||||
|
|
@ -130,7 +132,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("Сегодня");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -77,7 +77,7 @@ export const LoginPage = () => {
|
||||||
error={displayError}
|
error={displayError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isDemoMode || import.meta.env.DEV) ? (
|
{isDemoMode ? (
|
||||||
<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 ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -27,6 +28,10 @@ export const router = createBrowserRouter([
|
||||||
path: "dashboard",
|
path: "dashboard",
|
||||||
element: <DashboardPage />,
|
element: <DashboardPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "dashboard/group/:groupId",
|
||||||
|
element: <GroupDetailPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: <NotFoundPage />,
|
element: <NotFoundPage />,
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,28 @@ 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",
|
||||||
|
|
@ -22,7 +33,7 @@ export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||||||
"delivered",
|
"delivered",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["agreed", "driver_assigned", "loaded", "on_route", "problem"];
|
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
|
||||||
|
|
||||||
const HALF_DAY_LABELS = {
|
const HALF_DAY_LABELS = {
|
||||||
morning: "Первая половина дня",
|
morning: "Первая половина дня",
|
||||||
|
|
@ -46,7 +57,7 @@ const normalizeDeliveryHalfDayLabel = (value) => {
|
||||||
return HALF_DAY_LABELS.afternoon;
|
return HALF_DAY_LABELS.afternoon;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseJsonIfNeeded = (value) => {
|
const parseJsonIfNeeded = (value) => {
|
||||||
|
|
@ -128,18 +139,39 @@ 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;
|
||||||
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
|
||||||
|
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;
|
||||||
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
// Unify manual_required into a single bucket regardless of delivery_status detail
|
||||||
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +183,7 @@ export const isOrderGroupVisibleToDriver = (group) => {
|
||||||
return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus);
|
return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseGroupDate = (value) => {
|
export const parseGroupDate = (value) => {
|
||||||
const normalized = normalizeDate(value);
|
const normalized = normalizeDate(value);
|
||||||
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|
@ -219,6 +251,8 @@ 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(" "))
|
||||||
|
|
@ -257,6 +291,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -269,43 +311,55 @@ 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: "status:ready_for_notification", label: ORDER_GROUP_STATUS_LABELS.ready_for_notification },
|
{ value: "delivery:pending_confirmation", label: DELIVERY_GROUP_STATUS_LABELS.pending_confirmation },
|
||||||
|
{ 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:sms_sent", label: ORDER_GROUP_STATUS_LABELS.sms_sent },
|
{ value: "status:manual_required", label: "Требует ручной обработки" },
|
||||||
{ value: "status:manual_work", label: ORDER_GROUP_STATUS_LABELS.manual_work },
|
{ value: "status:second_sms_sent", label: "Повторное SMS" },
|
||||||
|
{ 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) => {
|
||||||
if (status === "agreed") {
|
switch (status) {
|
||||||
return "accent";
|
case "pending_confirmation":
|
||||||
|
return "neutral";
|
||||||
|
case "manual_confirmation_required":
|
||||||
|
return "warning";
|
||||||
|
case "agreed":
|
||||||
|
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) => {
|
||||||
|
|
@ -343,6 +397,12 @@ 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";
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +457,14 @@ 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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export const filterOrdersByView = ({ orders, currentUser, filters, now }) => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUser.role === "manager" || currentUser.role === "admin") {
|
if (currentUser.role === "manager" || currentUser.role === "admin" || currentUser.role === "mega_admin") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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 {
|
||||||
|
|
@ -6,6 +7,8 @@ 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 };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
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 || [];
|
||||||
|
}, "Ошибка загрузки журнала действий");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
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,
|
||||||
|
|
@ -77,7 +80,40 @@ 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,
|
||||||
|
|
@ -96,12 +132,21 @@ 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,
|
||||||
|
|
@ -112,6 +157,8 @@ 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,
|
||||||
|
|
@ -120,6 +167,7 @@ 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,
|
||||||
|
|
@ -133,6 +181,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
customerPhone,
|
customerPhone,
|
||||||
customerDate,
|
customerDate,
|
||||||
deliveryAddress,
|
deliveryAddress,
|
||||||
|
customerAddress,
|
||||||
|
city,
|
||||||
rawDeliveryHalfDay,
|
rawDeliveryHalfDay,
|
||||||
rawDeliveryTime,
|
rawDeliveryTime,
|
||||||
row.delivery_window,
|
row.delivery_window,
|
||||||
|
|
@ -148,6 +198,8 @@ 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(" ")
|
||||||
|
|
@ -162,39 +214,198 @@ export const updateOrderGroupDeliveryChoice = async ({
|
||||||
}) => {
|
}) => {
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const { data, error } = await client
|
const updateResult = 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("*")
|
.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)")
|
||||||
.order("updated_at", { ascending: false });
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (data || []).map(mapOrderGroupRowToDeliveryGroup).filter(Boolean);
|
// Load driver names to patch groups where assigned_driver join is missing
|
||||||
|
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);
|
||||||
}, "Ошибка загрузки групп доставки");
|
}, "Ошибка загрузки групп доставки");
|
||||||
};
|
};
|
||||||
|
|
@ -116,10 +116,13 @@ describe("updateOrderGroupDeliveryChoice", () => {
|
||||||
selectMock.mockReset();
|
selectMock.mockReset();
|
||||||
singleMock.mockReset();
|
singleMock.mockReset();
|
||||||
|
|
||||||
fromMock.mockReturnValue({ update: updateMock });
|
fromMock
|
||||||
|
.mockReturnValueOnce({ update: updateMock })
|
||||||
|
.mockReturnValueOnce({ select: selectMock });
|
||||||
updateMock.mockReturnValue({ eq: eqMock });
|
updateMock.mockReturnValue({ eq: eqMock });
|
||||||
eqMock.mockReturnValue({ select: selectMock });
|
eqMock.mockReturnValueOnce({ error: null, status: 200, statusText: "OK" })
|
||||||
selectMock.mockReturnValue({ single: singleMock });
|
.mockReturnValueOnce({ single: singleMock });
|
||||||
|
selectMock.mockReturnValue({ eq: eqMock });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the group directly in order_groups", async () => {
|
it("updates the group directly in order_groups", async () => {
|
||||||
|
|
@ -163,7 +166,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("*");
|
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(singleMock).toHaveBeenCalledTimes(1);
|
expect(singleMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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("*, order_history(*), delivery_slots(*), chat_messages(*), order_logisticians(*)")
|
.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)")
|
||||||
.order("updated_at", { ascending: false });
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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 = () => {
|
||||||
|
|
@ -41,3 +42,28 @@ 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;
|
||||||
|
}, "Ошибка загрузки водителей");
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
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 };
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
v2.99.0
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
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,6 +19,13 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -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,6 +212,45 @@ 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,
|
||||||
|
|
@ -221,6 +260,11 @@ 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,
|
||||||
|
|
@ -229,7 +273,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: orderNumbers.map((number) => ({ name: number, quantity: "" })),
|
orderItems,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,7 @@ const resolveAllowedOrigins = (mode: CorsMode) => {
|
||||||
return Array.from(new Set(configured));
|
return Array.from(new Set(configured));
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentMode = readEnv("NODE_ENV") || "development";
|
return [];
|
||||||
if (currentMode === "production") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...DEFAULT_LOCAL_ORIGINS];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
|
|
@ -341,6 +336,40 @@ 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: (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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 {
|
||||||
|
|
@ -14,6 +15,7 @@ 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;
|
||||||
|
|
@ -65,6 +67,19 @@ 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,
|
||||||
|
|
@ -74,6 +89,14 @@ 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));
|
||||||
|
|
@ -169,6 +192,19 @@ 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",
|
||||||
|
|
@ -321,4 +357,4 @@ Deno.serve(async (request) => {
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -19,6 +19,7 @@ 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;
|
||||||
|
|
@ -150,7 +151,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)
|
? normalizeAvailableSlots(body.availableSlots).slice(0, MAX_SLOTS)
|
||||||
: buildDefaultDatedAvailableSlots();
|
: buildDefaultDatedAvailableSlots();
|
||||||
|
|
||||||
const invitationPayload = {
|
const invitationPayload = {
|
||||||
|
|
@ -163,7 +164,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() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
sent_at: null,
|
sent_at: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -321,7 +322,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() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
sent_at: new Date().toISOString(),
|
sent_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import { getOrderUpdateForDeliveryInvitationAction } from "../_shared/delivery-invitations.ts";
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
import { requireUuid } from "../_shared/security.ts";
|
||||||
} 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 {
|
||||||
|
|
@ -42,6 +41,12 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createAnonClient } 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 = createServiceClient();
|
const supabase = createAnonClient();
|
||||||
const emailHash = await hashText(email);
|
const emailHash = await hashText(email);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createAnonClient } from "../_shared/chatbot.ts";
|
||||||
import {
|
import {
|
||||||
getClientIp,
|
getClientIp,
|
||||||
getCorsHeaders,
|
getCorsHeaders,
|
||||||
|
|
@ -7,6 +7,7 @@ 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;
|
||||||
|
|
@ -28,6 +29,19 @@ 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,
|
||||||
|
|
@ -43,7 +57,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 = createServiceClient();
|
const supabase = createAnonClient();
|
||||||
const emailHash = await hashText(email);
|
const emailHash = await hashText(email);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
|
@ -147,7 +147,10 @@ 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 (
|
||||||
|
|
@ -342,6 +345,9 @@ 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
|
||||||
|
|
@ -615,16 +621,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 := coalesce(
|
v_customer_name := case
|
||||||
nullif(v_group.customer_name, ''),
|
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_invitation.customer_name, '')
|
else null
|
||||||
);
|
end;
|
||||||
v_customer_phone := coalesce(
|
v_customer_phone := case
|
||||||
nullif(v_group.customer_phone, ''),
|
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_invitation.customer_phone, '')
|
else coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), 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
|
||||||
|
|
@ -964,13 +970,36 @@ 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 (public.current_role_name() = 'admin' or id = auth.uid());
|
using (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.current_role_name() = 'admin')
|
using (public.is_admin())
|
||||||
with check (public.current_role_name() = 'admin');
|
with check (public.is_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
|
||||||
|
|
@ -1072,18 +1101,25 @@ 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 (true);
|
using (
|
||||||
|
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'));
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role');
|
||||||
|
|
||||||
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'));
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role');
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -1177,3 +1213,155 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
-- 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();
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/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()
|
||||||
Loading…
Reference in New Issue