Compare commits
54 Commits
3e5be5edf1
...
c1e5f109be
| Author | SHA1 | Date |
|---|---|---|
|
|
c1e5f109be | |
|
|
20873bdc5f | |
|
|
f69a52779f | |
|
|
5e02f61567 | |
|
|
94f534ded3 | |
|
|
55d42bfdc8 | |
|
|
70ec6ec7a8 | |
|
|
5c957c3c05 | |
|
|
23c0ed78ef | |
|
|
d2ac10d849 | |
|
|
4215001ab7 | |
|
|
f264a53c0a | |
|
|
174c74ec0b | |
|
|
05d4d86d37 | |
|
|
f792ea6fb9 | |
|
|
b0370ed03e | |
|
|
a55e5857f8 | |
|
|
bbe578eb3a | |
|
|
45dbbd4cb3 | |
|
|
a879ad01c3 | |
|
|
b74add32ef | |
|
|
d04901d296 | |
|
|
cdb2ec7098 | |
|
|
17fe2125a5 | |
|
|
28ccb655d3 | |
|
|
7b773dd4aa | |
|
|
921b7dbf00 | |
|
|
05b81cf128 | |
|
|
b721b26ce8 | |
|
|
9fe8418d3e | |
|
|
0b0626daa5 | |
|
|
aefc588844 | |
|
|
693da3236e | |
|
|
7054390dcb | |
|
|
54386e9710 | |
|
|
8d3d8dc0fe | |
|
|
d28e00782b | |
|
|
f1e2b47396 | |
|
|
37ea7d3a19 | |
|
|
9408b98f06 | |
|
|
5867272873 | |
|
|
6184f5aeed | |
|
|
c288251cec | |
|
|
b888eba09b | |
|
|
187ef90c96 | |
|
|
158026b49f | |
|
|
55c91d8d32 | |
|
|
41272bdbbf | |
|
|
3847625005 | |
|
|
a687d0b8a8 | |
|
|
ae1e0a2e0e | |
|
|
efa2a83634 | |
|
|
491b7705fd | |
|
|
d313a3b6b9 |
10
Caddyfile
10
Caddyfile
|
|
@ -2,18 +2,26 @@
|
||||||
|
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
|
|
||||||
@static path /assets/* /icons/* /manifest.webmanifest /service-worker.js
|
@static path /assets/* /icons/* /manifest.webmanifest
|
||||||
handle @static {
|
handle @static {
|
||||||
|
header Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /service-worker.js {
|
||||||
|
header Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
|
header Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
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'"
|
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'"
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ RUN npm install --prefer-offline
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_SUPABASE_URL
|
ARG VITE_SUPABASE_URL
|
||||||
ARG VITE_SUPABASE_ANON_KEY
|
ARG VITE_SUPABASE_ANON_KEY
|
||||||
ARG VITE_SUPABASE_SERVICE_ROLE_KEY
|
|
||||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
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
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ services:
|
||||||
args:
|
args:
|
||||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
||||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
|
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:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
- `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. Неизвестный email показывает подсказку обратиться к администратору.
|
- `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью. Неизвестный email показывает подсказку обратиться к администратору.
|
||||||
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
|
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
|
||||||
|
- `src/context/FontSettingsContext.jsx` — настройки размера шрифтов (6 категорий: таблицы, карточки, меню, заголовки, основной текст, мелкий текст). Сохранение в `localStorage` (ключ `supersam-font-settings`). Применяет CSS-переменные `--fs-scale-*` на `:root`.
|
||||||
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
|
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
|
||||||
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
|
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров, действий и **сгруппированных наборов доставки** (deliverySetBuckets).
|
||||||
- `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`.
|
- `src/hooks/useOrderGroups.js` — работа с группами заказов: загрузка, обновление статусов, ручное согласование доставки и самовывоза. Метод `saveManualDeliveryChoice` принимает `deliveryType`, `pickupDate`, `pickupTimeSlot`.
|
||||||
|
|
@ -101,3 +102,25 @@ Edge function `confirm-delivery-choice` передаёт `p_delivery_type`, `p_p
|
||||||
- Docker-сборка через `docker-compose.app.yml` (multi-stage: Node.js build → Caddy serve).
|
- Docker-сборка через `docker-compose.app.yml` (multi-stage: Node.js build → Caddy serve).
|
||||||
- Автодеплой: Gitea webhook → systemd `supersam-webhook` → `deploy.sh` (git pull + docker build).
|
- Автодеплой: Gitea webhook → systemd `supersam-webhook` → `deploy.sh` (git pull + docker build).
|
||||||
- Домен: `https://dost.supersamsev.ru/`
|
- Домен: `https://dost.supersamsev.ru/`
|
||||||
|
- Dev: `https://dev.mkn8n.ru/`
|
||||||
|
- SW инвалидация: `construction-delivery-static-vNN` → `--no-cache` rebuild.
|
||||||
|
- **Важно**: `sed` для bump SW версии — только `construction-delivery-static-vNN` и `runtime-vNN`, НЕ `v[0-9]*` (ломает `addEventListener`).
|
||||||
|
|
||||||
|
## SMS-кампании
|
||||||
|
|
||||||
|
См. `docs/sms-campaigns.md` — полная документация.
|
||||||
|
4 systemd timer+service+Python скрипта, сервер 217.114.5.8.
|
||||||
|
|
||||||
|
## Настройки интерфейса
|
||||||
|
|
||||||
|
- `src/pages/SettingsPage.jsx` — UI настроек (пресеты + слайдеры).
|
||||||
|
- `src/styles/fontSettings.css` — CSS-зоны `.fs-zone-*` оверрайдят Tailwind `text-*`.
|
||||||
|
- Порядок CSS: body FIRST, затем table/card/nav/heading/small — специфичные зоны побеждают.
|
||||||
|
- Arbitrary px sizes (`text-[10px]` — `text-[14px]`) требуют явных оверрайдов.
|
||||||
|
- `localStorage` ключ: `supersam-font-settings`.
|
||||||
|
|
||||||
|
## Stale подсветка
|
||||||
|
|
||||||
|
- `isStale(group)` — `updatedAt > 24ч` и статус не в AGREED_STATUSES.
|
||||||
|
- Жёлтый фон `bg-[rgba(191,123,33,0.06)]` на строке таблицы.
|
||||||
|
- `isLinkOpened(group)` — 👁 иконка если клиент открывал ссылку.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# SMS Campaigns — Архитектура
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
4 сценария SMS-рассылок, каждый — systemd timer + service + Python скрипт.
|
||||||
|
Сервер: `217.114.5.8`, скрипты в `/opt/supersam/scripts/`.
|
||||||
|
|
||||||
|
## Сценарии
|
||||||
|
|
||||||
|
| Сценарий | Timer | Скрипт | Триггер |
|
||||||
|
|----------|-------|--------|---------|
|
||||||
|
| 1-я SMS | `sms-first-campaign.timer` | `sms_first_campaign.py` | `notification_status=link_ready` + `delivery_status=pending_confirmation` |
|
||||||
|
| 2-я SMS | `sms-second-campaign.timer` | `sms_second_campaign.py` | `notification_status=first_sms_sent` + время прошло |
|
||||||
|
| Ручное управление | `sms-manual-campaign.timer` | `sms_manual_campaign.py` | `second_sms_sent` + expired, или `link_ready` без SMS > 3ч, или `sms_sending` > 3ч |
|
||||||
|
| Платное хранение | `sms-paid-storage-campaign.timer` | `sms_paid_storage_campaign.py` | `notification_status=paid_storage` + `paid_storage_at` |
|
||||||
|
|
||||||
|
## Timer конфигурация
|
||||||
|
|
||||||
|
- `OnUnitActiveSec=600` — каждые 10 минут
|
||||||
|
- `sms_timer_manager.sh` — управляет `timer_active` + `run_requested` + `needs_check`
|
||||||
|
- `fcntl.flock` во всех скриптах — защита от параллельного запуска
|
||||||
|
- `send_telegram` = no-op (pass)
|
||||||
|
|
||||||
|
## Логика retry
|
||||||
|
|
||||||
|
- 2 попытки отправки SMS с интервалом ~90 мин
|
||||||
|
- Если код SMS.ru не 103 (доставлено) после 2 попыток → `manual_required`
|
||||||
|
- `effective_attempts = max(sms_attempts, total_failed)` — total_failed из всей истории `sms_campaign_log`
|
||||||
|
- Код 231 (лимит одинаковых) → `send_failed`, НЕ повторять
|
||||||
|
|
||||||
|
## Рабочие часы
|
||||||
|
|
||||||
|
| Параметр | 1/2 SMS | Manual | Paid Storage |
|
||||||
|
|----------|---------|--------|-------------|
|
||||||
|
| Часы | 8–21 | 8–21 | 8–21 |
|
||||||
|
| Дни | Пн–Пт (1-5) | Пн–Сб (1-6) | Пн–Пт |
|
||||||
|
| Проверка статусов | 24/7 | 24/7 | 24/7 |
|
||||||
|
|
||||||
|
## Настройки (БД `sms_campaign_settings`)
|
||||||
|
|
||||||
|
- `test_mode` — false (1/2 SMS), true (manual/paid_storage)
|
||||||
|
- `test_phone` — 79788382260
|
||||||
|
- `timer_active` — управляет включением systemd timer
|
||||||
|
- `send_interval_seconds` — пауза между отправками (15с)
|
||||||
|
- `work_hours_start/end` — 8/21
|
||||||
|
- `work_days` — "1,2,3,4,5" или "1,2,3,4,5,6"
|
||||||
|
- `last_run_at` — обновляется при каждом запуске скрипта
|
||||||
|
- `last_balance` — баланс sms.ru после последней отправки
|
||||||
|
|
||||||
|
## SMS.ru
|
||||||
|
|
||||||
|
- Провайдер: sms.ru
|
||||||
|
- Коды: 100 (в очереди), 102 (в пути), 103 (доставлено), 104-108 (ошибка), 231/232 (лимит)
|
||||||
|
- API ключ хранится в `sms_campaign_settings`
|
||||||
|
- Баланс отображается в UI (SmsCampaignPanel)
|
||||||
|
|
||||||
|
## UI (SmsCampaignPanel.jsx)
|
||||||
|
|
||||||
|
- `getRunnerStatus(settings)` — показывает статус последнего запуска:
|
||||||
|
- 🟢 Работает (Xм назад) — < 10 мин
|
||||||
|
- 🟡 Xм назад — < 60 мин
|
||||||
|
- 🔴 Xч назад — < 24ч
|
||||||
|
- 🔴 Xд назад — > 24ч
|
||||||
|
- `🔴 Xд назад` = `last_run_at` был более 24 часов назад
|
||||||
|
- 4 вкладки кампаний, переключатель test/боевой, таймер on/off
|
||||||
|
- Логи отправок, фильтры (дата, открытие ссылки), bulk delete
|
||||||
|
- Edge function `check-sms-status` — мгновенная проверка SMS через sms.ru API
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
not_started → link_ready → sms_sending → checking → first_sms_sent
|
||||||
|
→ second_sms_sending → second_sms_sent → manual_required → confirmed
|
||||||
|
→ address_required → agreed → driver_assigned → loaded → on_route → delivered/picked_up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ссылки
|
||||||
|
|
||||||
|
- SMS ссылка содержит `?src=sms` — отличает открытие клиентом от админа
|
||||||
|
- `opened_at` ставится только при `p_src='sms'` в RPC `get_delivery_invitation_by_token`
|
||||||
|
- `access_count` растёт при любом открытии
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
- Скрипты: напрямую в `/opt/supersam/scripts/` (не в Docker)
|
||||||
|
- UI: SCP → `docker compose -f docker-compose.app.yml build --no-cache && up -d`
|
||||||
|
- SW bump: `sed -i 's/construction-delivery-static-v[0-9]*/vNN/g' public/service-worker.js`
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Allow drivers to update order_groups where they are assigned
|
||||||
|
-- This enables saving shipment data and updating delivery status
|
||||||
|
CREATE POLICY "order groups driver update shipment data"
|
||||||
|
ON public.order_groups
|
||||||
|
FOR UPDATE
|
||||||
|
TO public
|
||||||
|
USING (current_role_name() = 'driver' AND assigned_driver_id = auth.uid())
|
||||||
|
WITH CHECK (current_role_name() = 'driver' AND assigned_driver_id = auth.uid());
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
-- Migration: add source_orders items to get_delivery_invitation_by_token
|
-- Fix: get_delivery_invitation_by_token — return full order composition with items
|
||||||
-- This replaces ONLY the orderItems building section for the group path.
|
-- Problem: RPC returned only top-level nom (invoice number), not the product items inside orderList[].items[]
|
||||||
-- Apply AFTER the base function is restored.
|
-- Solution: Return nested structure {name, quantity, items: [{name, quantity, unit}]} matching flattenOrderProducts() expectations
|
||||||
|
|
||||||
-- 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)
|
CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
|
|
@ -21,14 +18,22 @@ DECLARE
|
||||||
v_customer_name text;
|
v_customer_name text;
|
||||||
v_customer_phone text;
|
v_customer_phone text;
|
||||||
v_order_items jsonb;
|
v_order_items jsonb;
|
||||||
v_order_numbers jsonb;
|
v_stop_words jsonb;
|
||||||
|
v_delivery_address text;
|
||||||
|
v_customer_address text;
|
||||||
|
v_delivery_type text;
|
||||||
|
v_pickup_code text;
|
||||||
|
v_is_payed_ship boolean;
|
||||||
|
v_payed_ship text;
|
||||||
|
v_pickup_date text;
|
||||||
|
v_pickup_time_slot 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;
|
||||||
|
|
||||||
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
v_token_hash := encode(extensions.digest(p_token, 'sha256'), 'hex');
|
||||||
|
|
||||||
SELECT *
|
SELECT *
|
||||||
INTO v_invitation
|
INTO v_invitation
|
||||||
|
|
@ -47,6 +52,10 @@ BEGIN
|
||||||
RAISE EXCEPTION 'Invitation expired';
|
RAISE EXCEPTION 'Invitation expired';
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
SELECT coalesce(jsonb_agg(word), '[]'::jsonb)
|
||||||
|
INTO v_stop_words
|
||||||
|
FROM public.stop_words;
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -60,6 +69,7 @@ BEGIN
|
||||||
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_group.delivery_status = 'pickup' THEN 'agreed'
|
||||||
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;
|
||||||
|
|
@ -80,60 +90,114 @@ 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 := COALESCE(
|
||||||
NULLIF(v_group.customer_name, ''),
|
NULLIF(v_group.customer_name, ''),
|
||||||
NULLIF(v_invitation.customer_name, '')
|
v_group.source_orders->0->>'customerName'
|
||||||
);
|
|
||||||
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,
|
v_customer_phone := COALESCE(
|
||||||
-- otherwise fall back to invoice numbers from order_numbers.
|
NULLIF(v_group.customer_phone, ''),
|
||||||
v_order_items := CASE
|
v_group.source_orders->0->>'customerPhone'
|
||||||
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
|
-- Build orderItems: preserve nested structure {name, quantity, items: [...]}
|
||||||
THEN COALESCE(
|
-- so flattenOrderProducts() in OrderCompositionPanel.jsx can extract individual products
|
||||||
(SELECT jsonb_agg(
|
IF v_group.source_orders IS NOT NULL AND jsonb_typeof(v_group.source_orders) = 'array' THEN
|
||||||
jsonb_build_object(
|
SELECT jsonb_agg(jsonb_build_object(
|
||||||
'name', COALESCE(src ->> 'nom', src ->> 'name', ''),
|
'name', COALESCE(
|
||||||
|
NULLIF(src ->> 'nom', ''),
|
||||||
|
NULLIF(src ->> 'name', ''),
|
||||||
|
'Товар'
|
||||||
|
),
|
||||||
'quantity', '',
|
'quantity', '',
|
||||||
'items', COALESCE(src -> 'orderList', src -> 'items', '[]'::jsonb)
|
'items', COALESCE(
|
||||||
)
|
(SELECT jsonb_agg(jsonb_build_object(
|
||||||
) FROM jsonb_array_elements(v_group.source_orders) AS src),
|
'name', COALESCE(ol ->> 'nom', ol ->> 'name', ''),
|
||||||
|
'quantity', '',
|
||||||
|
'items', COALESCE(ol -> 'items', '[]'::jsonb)
|
||||||
|
))
|
||||||
|
FROM jsonb_array_elements(src -> 'orderList') AS ol
|
||||||
|
WHERE jsonb_typeof(ol -> 'items') = 'array'
|
||||||
|
AND jsonb_array_length(ol -> 'items') > 0),
|
||||||
|
(SELECT jsonb_agg(jsonb_build_object(
|
||||||
|
'name', COALESCE(ol ->> 'nom', ol ->> 'name', ''),
|
||||||
|
'quantity', ''
|
||||||
|
))
|
||||||
|
FROM jsonb_array_elements(src -> 'orderList') AS ol),
|
||||||
'[]'::jsonb
|
'[]'::jsonb
|
||||||
)
|
)
|
||||||
ELSE COALESCE(
|
))
|
||||||
|
INTO v_order_items
|
||||||
|
FROM jsonb_array_elements(v_group.source_orders) AS src
|
||||||
|
WHERE jsonb_typeof(src -> 'orderList') = 'array';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_order_items IS NULL THEN
|
||||||
|
-- Fallback: use invoice numbers from order_numbers
|
||||||
|
SELECT COALESCE(
|
||||||
(SELECT jsonb_agg(jsonb_build_object('name', onum, 'quantity', ''))
|
(SELECT jsonb_agg(jsonb_build_object('name', onum, 'quantity', ''))
|
||||||
FROM unnest(v_group.order_numbers) AS onum
|
FROM unnest(v_group.order_numbers) AS onum
|
||||||
WHERE onum IS NOT NULL AND onum <> ''),
|
WHERE onum IS NOT NULL AND onum <> ''),
|
||||||
'[]'::jsonb
|
'[]'::jsonb
|
||||||
)
|
) INTO v_order_items;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_delivery_address := COALESCE(
|
||||||
|
NULLIF(v_group.delivery_address, ''),
|
||||||
|
v_group.source_orders->0->>'adress',
|
||||||
|
v_group.source_orders->0->>'address',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
v_customer_address := COALESCE(
|
||||||
|
NULLIF(v_group.customer_address, ''),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
v_delivery_address := CASE
|
||||||
|
WHEN v_delivery_address ILIKE '%САМОВЫВОЗ%' THEN ''
|
||||||
|
ELSE v_delivery_address
|
||||||
END;
|
END;
|
||||||
|
|
||||||
|
-- Pickup fields
|
||||||
|
v_delivery_type := COALESCE(NULLIF(v_group.delivery_type, ''), 'delivery');
|
||||||
|
v_pickup_code := COALESCE(NULLIF(v_group.source_orders->0->>'rnd', ''), NULLIF(v_group.pickup_code, ''));
|
||||||
|
v_is_payed_ship := COALESCE(v_group.is_payed_ship, false);
|
||||||
|
v_payed_ship := COALESCE(NULLIF(v_group.payed_ship, ''), v_group.source_orders->0->>'payed_ship', '');
|
||||||
|
v_pickup_date := CASE WHEN v_group.pickup_date IS NOT NULL THEN v_group.pickup_date::text ELSE NULL END;
|
||||||
|
v_pickup_time_slot := COALESCE(NULLIF(v_group.pickup_time_slot, ''), NULLIF(v_group.source_orders->0->>'pickup_time_slot', ''));
|
||||||
|
|
||||||
RETURN jsonb_build_object(
|
RETURN jsonb_build_object(
|
||||||
'ok', TRUE,
|
'ok', TRUE,
|
||||||
'invitation', jsonb_build_object(
|
'invitation', jsonb_build_object(
|
||||||
'orderId', COALESCE(v_invitation.order_group_id, v_group.id)::text,
|
'orderId', COALESCE(v_invitation.order_group_id, v_invitation.order_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', COALESCE(v_order_items, '[]'::jsonb),
|
||||||
'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,
|
||||||
|
'stopWords', COALESCE(v_stop_words, '[]'::jsonb),
|
||||||
|
'deliveryType', v_delivery_type,
|
||||||
|
'deliveryAddress', v_delivery_address,
|
||||||
|
'customerAddress', v_customer_address,
|
||||||
|
'isPayedShip', v_is_payed_ship,
|
||||||
|
'payedShip', NULLIF(v_payed_ship, ''),
|
||||||
|
'pickupCode', NULLIF(v_pickup_code, ''),
|
||||||
|
'pickupDate', v_pickup_date,
|
||||||
|
'pickupTimeSlot', NULLIF(v_pickup_time_slot, '')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- Legacy single-order path
|
||||||
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
|
||||||
|
|
@ -185,7 +249,13 @@ BEGIN
|
||||||
'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,
|
||||||
|
'stopWords', COALESCE(v_stop_words, '[]'::jsonb),
|
||||||
|
'deliveryType', 'delivery',
|
||||||
|
'deliveryAddress', '',
|
||||||
|
'customerAddress', '',
|
||||||
|
'isPayedShip', false,
|
||||||
|
'payedShip', NULL
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
|
|
@ -193,214 +263,3 @@ $$;
|
||||||
|
|
||||||
REVOKE ALL ON FUNCTION public.get_delivery_invitation_by_token(text) FROM public;
|
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;
|
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_delivery_date date,
|
|
||||||
p_delivery_time 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_slot_label text;
|
|
||||||
v_now timestamptz := timezone('utc', now());
|
|
||||||
BEGIN
|
|
||||||
IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'token is required';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF p_delivery_date IS NULL OR nullif(trim(coalesce(p_delivery_time, '')), '') IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'Selected slot is not available';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
|
||||||
v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time));
|
|
||||||
|
|
||||||
SELECT *
|
|
||||||
INTO v_invitation
|
|
||||||
FROM public.delivery_invitations
|
|
||||||
WHERE token_hash = v_token_hash
|
|
||||||
FOR UPDATE;
|
|
||||||
|
|
||||||
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.state NOT IN ('awaiting_choice', 'opened', 'reminder_sent') THEN
|
|
||||||
RAISE EXCEPTION 'Invitation is no longer active';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
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';
|
|
||||||
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
|
|
||||||
FOR UPDATE;
|
|
||||||
|
|
||||||
IF NOT found THEN
|
|
||||||
RAISE EXCEPTION 'Order group not found';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_group.delivery_status <> 'pending_confirmation' THEN
|
|
||||||
RAISE EXCEPTION 'Invitation is no longer active';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
UPDATE public.delivery_invitations
|
|
||||||
SET
|
|
||||||
state = 'agreed',
|
|
||||||
delivery_date = p_delivery_date,
|
|
||||||
delivery_time = trim(p_delivery_time),
|
|
||||||
confirmed_at = v_now,
|
|
||||||
access_count = COALESCE(access_count, 0) + 1,
|
|
||||||
last_accessed_at = v_now
|
|
||||||
WHERE id = v_invitation.id;
|
|
||||||
|
|
||||||
UPDATE public.order_groups
|
|
||||||
SET
|
|
||||||
delivery_status = 'agreed',
|
|
||||||
delivery_date = p_delivery_date,
|
|
||||||
delivery_time = trim(p_delivery_time),
|
|
||||||
notification_status = 'confirmed',
|
|
||||||
updated_at = v_now
|
|
||||||
WHERE id = v_group.id;
|
|
||||||
|
|
||||||
INSERT INTO public.integration_events (
|
|
||||||
order_id,
|
|
||||||
event_type,
|
|
||||||
direction,
|
|
||||||
status,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
NULL,
|
|
||||||
'delivery_choice_confirmed',
|
|
||||||
'inbound',
|
|
||||||
'success',
|
|
||||||
jsonb_build_object(
|
|
||||||
'order_group_id', v_group.id,
|
|
||||||
'delivery_invitation_id', v_invitation.id,
|
|
||||||
'delivery_date', p_delivery_date,
|
|
||||||
'delivery_time', trim(p_delivery_time)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN jsonb_build_object(
|
|
||||||
'ok', TRUE,
|
|
||||||
'orderGroupId', v_group.id,
|
|
||||||
'deliveryStatus', 'agreed'
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT id, status, delivery_agreement_status
|
|
||||||
INTO v_order
|
|
||||||
FROM public.orders
|
|
||||||
WHERE id = v_invitation.order_id
|
|
||||||
FOR UPDATE;
|
|
||||||
|
|
||||||
IF NOT found THEN
|
|
||||||
RAISE EXCEPTION 'Order not found';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_order.status NOT IN ('Ожидает ответа клиента', 'Ожидает согласования доставки') THEN
|
|
||||||
RAISE EXCEPTION 'Invitation is no longer active';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
UPDATE public.delivery_invitations
|
|
||||||
SET
|
|
||||||
state = 'agreed',
|
|
||||||
delivery_date = p_delivery_date,
|
|
||||||
delivery_time = trim(p_delivery_time),
|
|
||||||
confirmed_at = v_now,
|
|
||||||
access_count = COALESCE(access_count, 0) + 1,
|
|
||||||
last_accessed_at = v_now
|
|
||||||
WHERE id = v_invitation.id;
|
|
||||||
|
|
||||||
UPDATE public.orders
|
|
||||||
SET
|
|
||||||
status = 'Доставка согласована',
|
|
||||||
delivery_agreement_status = 'Подтверждено клиентом'
|
|
||||||
WHERE id = v_order.id;
|
|
||||||
|
|
||||||
INSERT INTO public.delivery_slots (
|
|
||||||
order_id,
|
|
||||||
delivery_date,
|
|
||||||
delivery_time,
|
|
||||||
logistician_id,
|
|
||||||
status
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
v_order.id,
|
|
||||||
p_delivery_date,
|
|
||||||
trim(p_delivery_time),
|
|
||||||
NULL,
|
|
||||||
'confirmed_by_client'
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO public.order_history (
|
|
||||||
order_id,
|
|
||||||
action,
|
|
||||||
old_status,
|
|
||||||
new_status,
|
|
||||||
metadata
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
v_order.id,
|
|
||||||
'Подтверждение выбора доставки клиентом',
|
|
||||||
v_order.status,
|
|
||||||
'Доставка согласована',
|
|
||||||
jsonb_build_object(
|
|
||||||
'old_delivery_agreement_status', v_order.delivery_agreement_status,
|
|
||||||
'new_delivery_agreement_status', 'Подтверждено клиентом',
|
|
||||||
'delivery_date', p_delivery_date,
|
|
||||||
'delivery_time', trim(p_delivery_time)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO public.integration_events (
|
|
||||||
order_id,
|
|
||||||
event_type,
|
|
||||||
direction,
|
|
||||||
status,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
v_order.id,
|
|
||||||
'delivery_choice_confirmed',
|
|
||||||
'inbound',
|
|
||||||
'success',
|
|
||||||
jsonb_build_object(
|
|
||||||
'delivery_date', p_delivery_date,
|
|
||||||
'delivery_time', trim(p_delivery_time)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN jsonb_build_object(
|
|
||||||
'ok', TRUE,
|
|
||||||
'orderId', v_order.id,
|
|
||||||
'status', 'Доставка согласована',
|
|
||||||
'deliveryAgreementStatus', 'Подтверждено клиентом'
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
"name": "construction-delivery",
|
"name": "construction-delivery",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@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",
|
||||||
|
|
@ -16,6 +19,7 @@
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.3.0",
|
"react-router-dom": "7.3.0",
|
||||||
|
"recharts": "^2.15.0",
|
||||||
"tailwind-merge": "3.3.0"
|
"tailwind-merge": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -281,6 +285,15 @@
|
||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
|
|
@ -329,6 +342,59 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
|
@ -1562,6 +1628,69 @@
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"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",
|
||||||
|
|
@ -2445,9 +2574,129 @@
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
|
|
@ -2530,6 +2779,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-eql": {
|
"node_modules/deep-eql": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
|
|
@ -2610,6 +2865,16 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -3091,6 +3356,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
|
@ -3108,6 +3379,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
|
@ -3618,6 +3898,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -4222,6 +4511,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|
@ -4415,7 +4710,6 @@
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -4946,7 +5240,6 @@
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
|
|
@ -5014,7 +5307,6 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
|
|
@ -5067,6 +5359,37 @@
|
||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-smooth": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-equals": "^5.0.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-transition-group": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"dom-helpers": "^5.0.1",
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.6.0",
|
||||||
|
"react-dom": ">=16.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|
@ -5090,6 +5413,45 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "2.15.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
|
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||||
|
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"eventemitter3": "^4.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
|
"react-smooth": "^4.0.4",
|
||||||
|
"recharts-scale": "^0.4.4",
|
||||||
|
"tiny-invariant": "^1.3.1",
|
||||||
|
"victory-vendor": "^36.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts-scale": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decimal.js-light": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/react-is": {
|
||||||
|
"version": "18.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -5793,6 +6155,12 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|
@ -6087,6 +6455,28 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "36.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@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",
|
||||||
|
|
@ -20,8 +23,8 @@
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.3.0",
|
"react-router-dom": "7.3.0",
|
||||||
"tailwind-merge": "3.3.0",
|
"recharts": "^2.15.0",
|
||||||
"recharts": "^2.15.0"
|
"tailwind-merge": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v5";
|
const STATIC_CACHE = "construction-delivery-static-v49";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v5";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v49";
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,529 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — SMS First Campaign (State Machine)
|
||||||
|
Заменяет n8n workflow "Первая отправка согласования"
|
||||||
|
|
||||||
|
Архитектура: state machine через cron (каждые 5 мин)
|
||||||
|
Каждый запуск:
|
||||||
|
1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS)
|
||||||
|
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
||||||
|
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||||
|
|
||||||
|
Защита от повторной отправки (ДВОЙНАЯ):
|
||||||
|
1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
|
||||||
|
→ get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
|
||||||
|
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
|
||||||
|
3. Код 231/132 = лимит одинаковых → не повторять
|
||||||
|
|
||||||
|
needs_check=true — admin нажал "Проверить снова" в UI
|
||||||
|
→ скрипт проверяет даже если запись старше max_check_duration
|
||||||
|
|
||||||
|
Коды sms.ru:
|
||||||
|
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||||||
|
103 = доставлено → цель
|
||||||
|
104-108, 130-132, 230-232 = ошибки доставки / лимиты
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
||||||
|
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
||||||
|
|
||||||
|
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
||||||
|
SMS_SEND_URL = "https://sms.ru/sms/send"
|
||||||
|
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
||||||
|
|
||||||
|
LOG_FILE = "/var/log/supersam-sms-first.log"
|
||||||
|
|
||||||
|
# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode)
|
||||||
|
# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone
|
||||||
|
# Мегаадмин выключает через админку → SMS идут реальным клиентам
|
||||||
|
|
||||||
|
# Коды, которые означают "в процессе" (ждём дальше)
|
||||||
|
IN_TRANSIT_CODES = {"100", "101", "102"}
|
||||||
|
# Код доставки
|
||||||
|
DELIVERED_CODE = "103"
|
||||||
|
# Коды ошибок доставки (не временные, можно повторять отправку)
|
||||||
|
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||||||
|
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
||||||
|
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
||||||
|
|
||||||
|
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("sms_first")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_db_conn():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||||||
|
user=DB_USER, password=DB_PASS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_settings(conn):
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"wait_between_checks_seconds": 25,
|
||||||
|
"max_check_duration_minutes": 90,
|
||||||
|
"max_attempts": 2,
|
||||||
|
"enabled": True,
|
||||||
|
"telegram_chat_id": "25164483",
|
||||||
|
"sms_api_id": SMS_API_ID,
|
||||||
|
"test_mode": True,
|
||||||
|
"test_phone": "79788382260",
|
||||||
|
}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_phone(phone):
|
||||||
|
"""Нормализует телефон: только цифры, начинается с 7"""
|
||||||
|
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||||
|
if clean.startswith("8"):
|
||||||
|
clean = "7" + clean[1:]
|
||||||
|
elif not clean.startswith("7"):
|
||||||
|
clean = "7" + clean
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def send_sms(phone, message, api_id):
|
||||||
|
"""Отправляет SMS, возвращает (sms_id, raw_response)"""
|
||||||
|
try:
|
||||||
|
clean_phone = normalize_phone(phone)
|
||||||
|
resp = requests.post(SMS_SEND_URL, params={
|
||||||
|
"api_id": api_id,
|
||||||
|
"to": clean_phone,
|
||||||
|
}, data={"msg": message}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
||||||
|
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
|
||||||
|
if status_code == "100" and len(lines) >= 2:
|
||||||
|
sms_id = lines[1].strip()
|
||||||
|
return sms_id, text, "100"
|
||||||
|
else:
|
||||||
|
return None, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS send error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
def check_sms_status(sms_id, api_id):
|
||||||
|
"""Проверяет статус, возвращает (code, raw_response)"""
|
||||||
|
try:
|
||||||
|
resp = requests.post(SMS_STATUS_URL, params={
|
||||||
|
"api_id": api_id,
|
||||||
|
"sms_id": sms_id,
|
||||||
|
}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
||||||
|
|
||||||
|
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
||||||
|
return sms_status_code, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS status check error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(message, chat_id):
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning(f"Telegram error: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Telegram failed: {e}")
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_send(conn):
|
||||||
|
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.status = 'ready_to_launch'
|
||||||
|
AND og.delivery_status = 'pending_confirmation'
|
||||||
|
AND og.delivery_link IS NOT NULL
|
||||||
|
AND og.delivery_link != ''
|
||||||
|
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
|
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.campaign_type = 'first_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||||||
|
)
|
||||||
|
ORDER BY og.created_at ASC
|
||||||
|
""")
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
||||||
|
Включает:
|
||||||
|
- Записи младше max_duration_min (обычная автопроверка)
|
||||||
|
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
||||||
|
og.customer_name, og.group_key
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'first_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
scl.created_at > NOW() - INTERVAL '%s minutes'
|
||||||
|
OR scl.needs_check = true
|
||||||
|
)
|
||||||
|
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_expired(conn, max_duration_min):
|
||||||
|
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at,
|
||||||
|
og.customer_name, og.group_key, og.delivery_link
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'first_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
||||||
|
ORDER BY scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def insert_sms_log(conn, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cols = ", ".join(kwargs.keys())
|
||||||
|
placeholders = ", ".join(["%s"] * len(kwargs))
|
||||||
|
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
||||||
|
log_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return log_id
|
||||||
|
|
||||||
|
def update_sms_log(conn, log_id, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(log_id)
|
||||||
|
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def update_order_group(conn, group_id, fields):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(group_id)
|
||||||
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def step_send_new(conn, settings):
|
||||||
|
"""Шаг 1: Отправка SMS новым группам"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||||
|
|
||||||
|
groups = get_groups_to_send(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for group in groups:
|
||||||
|
group_id = str(group["id"])
|
||||||
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
|
delivery_link = group.get("delivery_link", "")
|
||||||
|
|
||||||
|
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = 'first_sms'
|
||||||
|
AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND status IN ('sent', 'checking', 'delivered')
|
||||||
|
""", (group_id,))
|
||||||
|
recent_count = cur.fetchone()[0]
|
||||||
|
if recent_count > 0:
|
||||||
|
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
||||||
|
|
||||||
|
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||||||
|
send_phone = phone
|
||||||
|
if settings.get("test_mode", True):
|
||||||
|
send_phone = settings.get("test_phone", "79788382260")
|
||||||
|
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
||||||
|
|
||||||
|
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
|
||||||
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
|
if sms_id:
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type="first_sms",
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_id=sms_id,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="sent",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
)
|
||||||
|
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
||||||
|
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "sms_sending",
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
||||||
|
sent_count += 1
|
||||||
|
else:
|
||||||
|
# Ошибка отправки
|
||||||
|
error = raw[:500] if raw else "Unknown error"
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type="first_sms",
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="send_failed",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
error_message=error,
|
||||||
|
)
|
||||||
|
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
||||||
|
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "send_failed",
|
||||||
|
"last_sms_error": error[:200],
|
||||||
|
})
|
||||||
|
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
||||||
|
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
def step_check_status(conn, settings):
|
||||||
|
"""Шаг 2: Проверка статуса ранее отправленных SMS"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||||
|
|
||||||
|
sms_list = get_sms_to_check(conn, max_duration)
|
||||||
|
log.info(f"Step 2: {len(sms_list)} SMS to check status")
|
||||||
|
|
||||||
|
delivered = 0
|
||||||
|
for item in sms_list:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
sms_id = item["sms_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
was_needs_check = item.get("needs_check", False)
|
||||||
|
|
||||||
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
||||||
|
# Сбрасываем needs_check + ставим checked_at
|
||||||
|
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
||||||
|
|
||||||
|
if code == DELIVERED_CODE:
|
||||||
|
# Доставлено!
|
||||||
|
log.info(f"Group {group_id}: SMS delivered (103)!")
|
||||||
|
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
||||||
|
|
||||||
|
next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat()
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "first_sms_sent",
|
||||||
|
"sms_attempts": attempts,
|
||||||
|
"first_sms_sent_at": "NOW()",
|
||||||
|
"last_sms_error": None,
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
"next_notification_check_at": next_check,
|
||||||
|
"status": "first_sms_sent",
|
||||||
|
})
|
||||||
|
send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat)
|
||||||
|
delivered += 1
|
||||||
|
|
||||||
|
elif code in IN_TRANSIT_CODES:
|
||||||
|
# В пути / в очереди — продолжаем ждать
|
||||||
|
log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
||||||
|
|
||||||
|
elif code in DELIVERY_ERROR_CODES:
|
||||||
|
# Ошибка доставки — можно повторить отправку
|
||||||
|
log.error(f"Group {group_id}: SMS delivery error (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="error", sms_code=code,
|
||||||
|
error_message=f"Delivery error: {code}")
|
||||||
|
# Переход к следующей попытке отправки (если есть)
|
||||||
|
if attempts < max_attempts:
|
||||||
|
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
|
||||||
|
update_sms_log(conn, log_id, status="expired")
|
||||||
|
# Сбрасываем notification_status → link_ready для повторной отправки
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "link_ready",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "send_failed",
|
||||||
|
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
|
elif code in LIMIT_ERROR_CODES:
|
||||||
|
# Превышен лимит — НЕ повторять отправку!
|
||||||
|
log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying")
|
||||||
|
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
|
||||||
|
error_message=f"Limit exceeded: code={code}")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "send_failed",
|
||||||
|
"last_sms_error": f"Limit exceeded (code={code}), sending blocked",
|
||||||
|
})
|
||||||
|
send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Неизвестный код — логируем, продолжаем проверять
|
||||||
|
log.warning(f"Group {group_id}: unknown SMS code: {code}")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code,
|
||||||
|
error_message=f"Unknown code: {code}")
|
||||||
|
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
def step_handle_expired(conn, settings):
|
||||||
|
"""Шаг 3: Обработка SMS с истёкшим сроком проверки"""
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
expired = get_sms_expired(conn, max_duration)
|
||||||
|
log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)")
|
||||||
|
|
||||||
|
for item in expired:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
delivery_link = item.get("delivery_link", "")
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||||
|
update_sms_log(conn, log_id, status="expired",
|
||||||
|
error_message=f"Not delivered in {max_duration} minutes")
|
||||||
|
|
||||||
|
if attempts < max_attempts:
|
||||||
|
# Сбрасываем notification_status → link_ready для повторной отправки
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "link_ready",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
|
||||||
|
else:
|
||||||
|
# Все попытки исчерпаны
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
||||||
|
})
|
||||||
|
send_telegram(
|
||||||
|
f"🔧 Требуется ручное управление: {name} ({phone})\n"
|
||||||
|
f"SMS не доставлена после {max_attempts} попыток",
|
||||||
|
tg_chat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("SMS First Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(conn)
|
||||||
|
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||||||
|
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||||||
|
f"max_attempts={settings.get('max_attempts')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# State machine — каждый шаг быстрый, без blocking
|
||||||
|
sent = step_send_new(conn, settings)
|
||||||
|
delivered = step_check_status(conn, settings)
|
||||||
|
step_handle_expired(conn, settings)
|
||||||
|
|
||||||
|
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
||||||
|
|
||||||
|
if sent > 0 or delivered > 0:
|
||||||
|
send_telegram(
|
||||||
|
f"📊 <b>Первая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
||||||
|
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log.info("SMS First Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=SuperSam SMS First Campaign
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 /opt/supersam/scripts/sms_first_campaign.py
|
||||||
|
Environment=DB_HOST=10.0.4.5
|
||||||
|
Environment=DB_PORT=5432
|
||||||
|
Environment=DB_NAME=postgres
|
||||||
|
Environment=DB_USER=supabase_admin
|
||||||
|
Environment=DB_PASS=4fe80bb21c7c3d17a8d8b226adf7a479
|
||||||
|
Environment=TELEGRAM_BOT_TOKEN=
|
||||||
|
Environment=TELEGRAM_CHAT_ID=25164483
|
||||||
|
Environment=SMS_API_ID=C92063B3-95ED-8559-157B-1946EB5A2486
|
||||||
|
WorkingDirectory=/opt/supersam
|
||||||
|
User=root
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=SuperSam SMS First Campaign Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Every 5 minutes, 8-19, Mon-Sat (matching n8n schedule)
|
||||||
|
OnCalendar=*:0/5
|
||||||
|
OnCalendar=*-*-* 08-19:0/5:00
|
||||||
|
Persistent=false
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
@ -0,0 +1,583 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — SMS First Campaign (State Machine)
|
||||||
|
Заменяет n8n workflow "Первая отправка согласования"
|
||||||
|
|
||||||
|
Архитектура: state machine через cron (каждые 5 мин)
|
||||||
|
Каждый запуск:
|
||||||
|
1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS)
|
||||||
|
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
||||||
|
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||||
|
|
||||||
|
Защита от повторной отправки (ДВОЙНАЯ):
|
||||||
|
1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
|
||||||
|
→ get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
|
||||||
|
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
|
||||||
|
3. Код 231/132 = лимит одинаковых → не повторять
|
||||||
|
|
||||||
|
needs_check=true — admin нажал "Проверить снова" в UI
|
||||||
|
→ скрипт проверяет даже если запись старше max_check_duration
|
||||||
|
|
||||||
|
Коды sms.ru:
|
||||||
|
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||||||
|
103 = доставлено → цель
|
||||||
|
104-108, 130-132, 230-232 = ошибки доставки / лимиты
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
||||||
|
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
||||||
|
|
||||||
|
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
||||||
|
SMS_SEND_URL = "https://sms.ru/sms/send"
|
||||||
|
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
||||||
|
|
||||||
|
LOG_FILE = "/var/log/supersam-sms-first.log"
|
||||||
|
|
||||||
|
# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode)
|
||||||
|
# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone
|
||||||
|
# Мегаадмин выключает через админку → SMS идут реальным клиентам
|
||||||
|
|
||||||
|
# Коды, которые означают "в процессе" (ждём дальше)
|
||||||
|
IN_TRANSIT_CODES = {"100", "101", "102"}
|
||||||
|
# Код доставки
|
||||||
|
DELIVERED_CODE = "103"
|
||||||
|
# Коды ошибок доставки (не временные, можно повторять отправку)
|
||||||
|
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||||||
|
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
||||||
|
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
||||||
|
|
||||||
|
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("sms_first")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_db_conn():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||||||
|
user=DB_USER, password=DB_PASS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_settings(conn):
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"wait_between_checks_seconds": 25,
|
||||||
|
"max_check_duration_minutes": 90,
|
||||||
|
"max_attempts": 2,
|
||||||
|
"enabled": True,
|
||||||
|
"telegram_chat_id": "25164483",
|
||||||
|
"sms_api_id": SMS_API_ID,
|
||||||
|
"test_mode": True,
|
||||||
|
"test_phone": "79788382260",
|
||||||
|
}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_phone(phone):
|
||||||
|
"""Нормализует телефон: только цифры, начинается с 7"""
|
||||||
|
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||||
|
if clean.startswith("8"):
|
||||||
|
clean = "7" + clean[1:]
|
||||||
|
elif not clean.startswith("7"):
|
||||||
|
clean = "7" + clean
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def send_sms(phone, message, api_id):
|
||||||
|
"""Отправляет SMS, возвращает (sms_id, raw_response)"""
|
||||||
|
try:
|
||||||
|
clean_phone = normalize_phone(phone)
|
||||||
|
resp = requests.post(SMS_SEND_URL, params={
|
||||||
|
"api_id": api_id,
|
||||||
|
"to": clean_phone,
|
||||||
|
}, data={"msg": message}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
||||||
|
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
|
||||||
|
if status_code == "100" and len(lines) >= 2:
|
||||||
|
sms_id = lines[1].strip()
|
||||||
|
return sms_id, text, "100"
|
||||||
|
else:
|
||||||
|
return None, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS send error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
def check_sms_status(sms_id, api_id):
|
||||||
|
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
|
||||||
|
try:
|
||||||
|
resp = requests.post(SMS_STATUS_URL, params={
|
||||||
|
"api_id": api_id,
|
||||||
|
"sms_id": sms_id,
|
||||||
|
}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
||||||
|
|
||||||
|
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
||||||
|
return sms_status_code, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS status check error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
def fetch_balance(api_id):
|
||||||
|
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
|
||||||
|
try:
|
||||||
|
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
||||||
|
text = resp.text
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
if lines[0].strip() == "100" and len(lines) >= 2:
|
||||||
|
return float(lines[1].strip()), text
|
||||||
|
return None, text
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Balance fetch error: {e}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(message, chat_id):
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning(f"Telegram error: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Telegram failed: {e}")
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_send(conn):
|
||||||
|
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.status = 'ready_to_launch'
|
||||||
|
AND og.delivery_status = 'pending_confirmation'
|
||||||
|
AND og.delivery_link IS NOT NULL
|
||||||
|
AND og.delivery_link != ''
|
||||||
|
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
|
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.campaign_type = 'first_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||||||
|
)
|
||||||
|
ORDER BY og.created_at ASC
|
||||||
|
""")
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
||||||
|
Включает:
|
||||||
|
- Записи младше max_duration_min (обычная автопроверка)
|
||||||
|
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
||||||
|
og.customer_name, og.group_key
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'first_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
scl.created_at > NOW() - INTERVAL '%s minutes'
|
||||||
|
OR scl.needs_check = true
|
||||||
|
)
|
||||||
|
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_expired(conn, max_duration_min):
|
||||||
|
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at,
|
||||||
|
og.customer_name, og.group_key, og.delivery_link
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'first_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
||||||
|
ORDER BY scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def insert_sms_log(conn, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cols = ", ".join(kwargs.keys())
|
||||||
|
placeholders = ", ".join(["%s"] * len(kwargs))
|
||||||
|
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
||||||
|
log_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return log_id
|
||||||
|
|
||||||
|
def update_sms_log(conn, log_id, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(log_id)
|
||||||
|
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def update_order_group(conn, group_id, fields):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(group_id)
|
||||||
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_within_work_hours(settings):
|
||||||
|
"""Проверка: сейчас рабочие часы.
|
||||||
|
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||||
|
"""
|
||||||
|
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||||
|
today_num = now_msk.weekday() + 1
|
||||||
|
allowed_days = set()
|
||||||
|
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
||||||
|
for part in str(work_days_str).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
allowed_days.add(int(part))
|
||||||
|
if today_num not in allowed_days:
|
||||||
|
return False
|
||||||
|
hour = now_msk.hour
|
||||||
|
start_h = settings.get("work_hours_start", 8)
|
||||||
|
end_h = settings.get("work_hours_end", 21)
|
||||||
|
return start_h <= hour < end_h
|
||||||
|
|
||||||
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def step_send_new(conn, settings):
|
||||||
|
"""Шаг 1: Отправка SMS новым группам"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||||
|
|
||||||
|
groups = get_groups_to_send(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for group in groups:
|
||||||
|
group_id = str(group["id"])
|
||||||
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
|
delivery_link = group.get("delivery_link", "")
|
||||||
|
|
||||||
|
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = 'first_sms'
|
||||||
|
AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND status IN ('sent', 'checking', 'delivered')
|
||||||
|
""", (group_id,))
|
||||||
|
recent_count = cur.fetchone()[0]
|
||||||
|
if recent_count > 0:
|
||||||
|
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
||||||
|
|
||||||
|
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||||||
|
send_phone = phone
|
||||||
|
if settings.get("test_mode", True):
|
||||||
|
send_phone = settings.get("test_phone", "79788382260")
|
||||||
|
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
||||||
|
|
||||||
|
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
|
||||||
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
|
if sms_id:
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type="first_sms",
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_id=sms_id,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="sent",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
)
|
||||||
|
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
||||||
|
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "sms_sending",
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
||||||
|
sent_count += 1
|
||||||
|
else:
|
||||||
|
# Ошибка отправки
|
||||||
|
error = raw[:500] if raw else "Unknown error"
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type="first_sms",
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="send_failed",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
error_message=error,
|
||||||
|
)
|
||||||
|
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
||||||
|
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "send_failed",
|
||||||
|
"last_sms_error": error[:200],
|
||||||
|
})
|
||||||
|
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
||||||
|
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
def step_check_status(conn, settings):
|
||||||
|
"""Шаг 2: Проверка статуса ранее отправленных SMS"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||||||
|
|
||||||
|
sms_list = get_sms_to_check(conn, max_duration)
|
||||||
|
log.info(f"Step 2: {len(sms_list)} SMS to check status")
|
||||||
|
|
||||||
|
delivered = 0
|
||||||
|
for item in sms_list:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
sms_id = item["sms_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
was_needs_check = item.get("needs_check", False)
|
||||||
|
|
||||||
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
||||||
|
# Сбрасываем needs_check + ставим checked_at
|
||||||
|
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
||||||
|
|
||||||
|
if code == DELIVERED_CODE:
|
||||||
|
# Доставлено!
|
||||||
|
log.info(f"Group {group_id}: SMS delivered (103)!")
|
||||||
|
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
||||||
|
|
||||||
|
next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat()
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "first_sms_sent",
|
||||||
|
"sms_attempts": attempts,
|
||||||
|
"first_sms_sent_at": "NOW()",
|
||||||
|
"last_sms_error": None,
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
"next_notification_check_at": next_check,
|
||||||
|
"status": "first_sms_sent",
|
||||||
|
})
|
||||||
|
send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat)
|
||||||
|
delivered += 1
|
||||||
|
|
||||||
|
elif code in IN_TRANSIT_CODES:
|
||||||
|
# В пути / в очереди — продолжаем ждать
|
||||||
|
log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
||||||
|
|
||||||
|
elif code in DELIVERY_ERROR_CODES:
|
||||||
|
# Ошибка доставки — можно повторить отправку
|
||||||
|
log.error(f"Group {group_id}: SMS delivery error (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="error", sms_code=code,
|
||||||
|
error_message=f"Delivery error: {code}")
|
||||||
|
# Переход к следующей попытке отправки (если есть)
|
||||||
|
if attempts < max_attempts:
|
||||||
|
log.info(f"Group {group_id}: will retry send on next run (attempt {attempts+1}/{max_attempts})")
|
||||||
|
update_sms_log(conn, log_id, status="expired")
|
||||||
|
# Сбрасываем notification_status → link_ready для повторной отправки
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "link_ready",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "send_failed",
|
||||||
|
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
|
elif code in LIMIT_ERROR_CODES:
|
||||||
|
# Превышен лимит — НЕ повторять отправку!
|
||||||
|
log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying")
|
||||||
|
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
|
||||||
|
error_message=f"Limit exceeded: code={code}")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "send_failed",
|
||||||
|
"last_sms_error": f"Limit exceeded (code={code}), sending blocked",
|
||||||
|
})
|
||||||
|
send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Неизвестный код — логируем, продолжаем проверять
|
||||||
|
log.warning(f"Group {group_id}: unknown SMS code: {code}")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code,
|
||||||
|
error_message=f"Unknown code: {code}")
|
||||||
|
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
def step_handle_expired(conn, settings):
|
||||||
|
"""Шаг 3: Обработка SMS с истёкшим сроком проверки"""
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
expired = get_sms_expired(conn, max_duration)
|
||||||
|
log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)")
|
||||||
|
|
||||||
|
for item in expired:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
delivery_link = item.get("delivery_link", "")
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||||
|
update_sms_log(conn, log_id, status="expired",
|
||||||
|
error_message=f"Not delivered in {max_duration} minutes")
|
||||||
|
|
||||||
|
if attempts < max_attempts:
|
||||||
|
# Сбрасываем notification_status → link_ready для повторной отправки
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "link_ready",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: will retry SMS send (attempt {attempts+1}), notification_status→link_ready")
|
||||||
|
else:
|
||||||
|
# Все попытки исчерпаны
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
||||||
|
})
|
||||||
|
send_telegram(
|
||||||
|
f"🔧 Требуется ручное управление: {name} ({phone})\n"
|
||||||
|
f"SMS не доставлена после {max_attempts} попыток",
|
||||||
|
tg_chat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("SMS First Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(conn)
|
||||||
|
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||||||
|
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||||||
|
f"max_attempts={settings.get('max_attempts')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# State machine — каждый шаг быстрый, без blocking
|
||||||
|
# Отправка только в рабочие часы
|
||||||
|
work_hours = is_within_work_hours(settings)
|
||||||
|
sent = 0
|
||||||
|
if work_hours:
|
||||||
|
sent = step_send_new(conn, settings)
|
||||||
|
else:
|
||||||
|
log.info("Outside work hours, skipping new SMS sends")
|
||||||
|
|
||||||
|
# Проверка статусов работает всегда
|
||||||
|
delivered = step_check_status(conn, settings)
|
||||||
|
step_handle_expired(conn, settings)
|
||||||
|
|
||||||
|
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
||||||
|
|
||||||
|
if sent > 0 or delivered > 0:
|
||||||
|
send_telegram(
|
||||||
|
f"📊 <b>Первая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
||||||
|
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем баланс sms.ru
|
||||||
|
if sent > 0:
|
||||||
|
balance, raw = fetch_balance(api_id)
|
||||||
|
if balance is not None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'",
|
||||||
|
(balance,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"Balance updated: {balance} ₽")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log.info("SMS First Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — Manual Campaign (State Machine)
|
||||||
|
Заменяет n8n workflow "Ручное управление"
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
Если после второй SMS (notification_status = 'second_sms_sent')
|
||||||
|
прошло auto_manual_after_hours и клиент не согласовал
|
||||||
|
(delivery_status = 'pending_confirmation'):
|
||||||
|
→ notification_status = 'manual_required'
|
||||||
|
→ delivery_status = 'manual_confirmation_required'
|
||||||
|
→ next_notification_check_at = +3 мин
|
||||||
|
→ Telegram-уведомление
|
||||||
|
|
||||||
|
Расписание: 9-21, Пн-Сб (настраивается из админки)
|
||||||
|
Скрипт НЕ отправляет SMS — только перевод в ручное управление.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import fcntl
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
||||||
|
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
||||||
|
|
||||||
|
LOG_FILE = "/var/log/supersam-sms-manual.log"
|
||||||
|
|
||||||
|
CAMPAIGN_TYPE = "manual"
|
||||||
|
|
||||||
|
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("sms_manual")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_db_conn():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||||||
|
user=DB_USER, password=DB_PASS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_settings(conn):
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(message, chat_id):
|
||||||
|
pass # Telegram notifications moved to n8n+Supabase integration
|
||||||
|
|
||||||
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_within_work_hours(settings):
|
||||||
|
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||||
|
today_num = now_msk.weekday() + 1
|
||||||
|
allowed_days = set()
|
||||||
|
work_days_str = settings.get("work_days", "1,2,3,4,5,6")
|
||||||
|
for part in str(work_days_str).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
allowed_days.add(int(part))
|
||||||
|
if today_num not in allowed_days:
|
||||||
|
return False
|
||||||
|
hour = now_msk.hour
|
||||||
|
start_h = settings.get("work_hours_start", 9)
|
||||||
|
end_h = settings.get("work_hours_end", 21)
|
||||||
|
return start_h <= hour < end_h
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_manual(conn):
|
||||||
|
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
|
||||||
|
|
||||||
|
Сценарии:
|
||||||
|
1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS
|
||||||
|
2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS
|
||||||
|
3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа
|
||||||
|
4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
og.notification_status, og.delivery_link,
|
||||||
|
og.first_sms_sent_at, og.second_sms_sent_at,
|
||||||
|
og.sms_sent_at, og.next_notification_check_at,
|
||||||
|
og.sms_attempts
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
|
AND (
|
||||||
|
-- 1. second_sms_sent + срок истёк
|
||||||
|
(COALESCE(og.notification_status, '') = 'second_sms_sent'
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()))
|
||||||
|
-- 2. first_sms_sent + срок истёк (>3h после отправки)
|
||||||
|
-- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL)
|
||||||
|
-- Иначе second_campaign не успеет отправить вторую SMS
|
||||||
|
OR (COALESCE(og.notification_status, '') = 'first_sms_sent'
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
|
AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours'
|
||||||
|
AND og.second_sms_sent_at IS NOT NULL)
|
||||||
|
-- 3. link_ready + next_check в прошлом + нет активных SMS в логе
|
||||||
|
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||||||
|
))
|
||||||
|
-- 4. sms_sending/checking + >3h без доставки (зависло)
|
||||||
|
OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking')
|
||||||
|
AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours')
|
||||||
|
)
|
||||||
|
ORDER BY og.created_at ASC
|
||||||
|
""")
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def update_order_group(conn, group_id, fields):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if isinstance(v, str) and v.startswith("NOW()"):
|
||||||
|
set_parts.append(f"{k} = {v}")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(group_id)
|
||||||
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def step_move_to_manual(conn, settings):
|
||||||
|
"""Перевод групп в ручное управление"""
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
delay_minutes = settings.get("auto_manual_after_hours", 3)
|
||||||
|
|
||||||
|
groups = get_groups_to_manual(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to move to manual")
|
||||||
|
|
||||||
|
moved = 0
|
||||||
|
for group in groups:
|
||||||
|
group_id = str(group["id"])
|
||||||
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
phone = group.get("customer_phone", "")
|
||||||
|
delivery_link = group.get("delivery_link", "")
|
||||||
|
|
||||||
|
log.info(f"Group {group_id}: moving to manual_required ({name})")
|
||||||
|
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"delivery_status": "manual_confirmation_required",
|
||||||
|
"sms_attempts": 2,
|
||||||
|
"last_sms_error": None,
|
||||||
|
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
|
||||||
|
"status": "manual_required",
|
||||||
|
})
|
||||||
|
|
||||||
|
send_telegram(
|
||||||
|
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
||||||
|
f"Клиент не согласовал доставку после двух SMS\n"
|
||||||
|
f"Ссылка: {delivery_link}",
|
||||||
|
tg_chat,
|
||||||
|
)
|
||||||
|
moved += 1
|
||||||
|
|
||||||
|
return moved
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# File lock — prevent parallel execution
|
||||||
|
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except (IOError, OSError):
|
||||||
|
log.info("Another instance is running, exiting")
|
||||||
|
lock_file.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("Manual Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(conn)
|
||||||
|
|
||||||
|
# Update last_run_at timestamp
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Check test_send_requested — one-time test send
|
||||||
|
test_send_requested = bool(settings.get("test_send_requested", False))
|
||||||
|
if test_send_requested:
|
||||||
|
# Reset flag immediately
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
conn.commit()
|
||||||
|
log.info("Test send requested — will send ONE SMS to test phone only")
|
||||||
|
log.info(f"Settings: work_hours={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
|
||||||
|
f"work_days={settings.get('work_days')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Только в рабочие часы
|
||||||
|
if not is_within_work_hours(settings):
|
||||||
|
log.info("Outside work hours, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
if test_send_requested:
|
||||||
|
log.info("Test send requested for manual campaign — no SMS to send, marking as done")
|
||||||
|
|
||||||
|
# run_requested for manual = just run the step
|
||||||
|
run_requested = bool(settings.get("run_requested", False))
|
||||||
|
if run_requested:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'")
|
||||||
|
conn.commit()
|
||||||
|
log.info("RESTART: manual campaign — will process all eligible groups now")
|
||||||
|
else:
|
||||||
|
moved = step_move_to_manual(conn, settings)
|
||||||
|
|
||||||
|
log.info(f"Run summary: moved_to_manual={moved}")
|
||||||
|
|
||||||
|
if moved > 0:
|
||||||
|
send_telegram(
|
||||||
|
f"📊 <b>Ручное управление</b>\nПереведено в ручное: {moved}",
|
||||||
|
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
lock_file.close()
|
||||||
|
|
||||||
|
log.info("Manual Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,469 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — Paid Storage Campaign (State Machine)
|
||||||
|
Уведомление о включении платного хранения.
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
Когда delivery_status = 'paid_storage' (логист/админ включил):
|
||||||
|
→ Если рабочие часы (8-21, Пн-Пт): отправить SMS один раз
|
||||||
|
→ Если ночью: ждать до утра
|
||||||
|
→ Проверить доставку (103)
|
||||||
|
→ Пометить notification_status = 'paid_storage_sent'
|
||||||
|
|
||||||
|
SMS текст: "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день.
|
||||||
|
Заберите заказ или согласуйте доставку: {link}"
|
||||||
|
|
||||||
|
Защита от повторной отправки:
|
||||||
|
1. После отправки → notification_status = 'paid_storage_sending'
|
||||||
|
2. EXISTS проверка в sms_campaign_log за 24h
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
||||||
|
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
||||||
|
|
||||||
|
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
||||||
|
SMS_SEND_URL = "https://sms.ru/sms/send"
|
||||||
|
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
||||||
|
|
||||||
|
LOG_FILE = "/var/log/supersam-sms-paid-storage.log"
|
||||||
|
|
||||||
|
CAMPAIGN_TYPE = "paid_storage"
|
||||||
|
|
||||||
|
IN_TRANSIT_CODES = {"100", "101", "102"}
|
||||||
|
DELIVERED_CODE = "103"
|
||||||
|
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||||||
|
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
||||||
|
|
||||||
|
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("sms_paid_storage")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_db_conn():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||||||
|
user=DB_USER, password=DB_PASS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_settings(conn):
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"enabled": True, "test_mode": True, "test_phone": "79788382260",
|
||||||
|
"max_check_duration_minutes": 90, "max_attempts": 2,
|
||||||
|
"telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID,
|
||||||
|
"work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5",
|
||||||
|
}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_phone(phone):
|
||||||
|
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||||
|
if clean.startswith("8"):
|
||||||
|
clean = "7" + clean[1:]
|
||||||
|
elif not clean.startswith("7"):
|
||||||
|
clean = "7" + clean
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def send_sms(phone, message, api_id):
|
||||||
|
try:
|
||||||
|
clean_phone = normalize_phone(phone)
|
||||||
|
resp = requests.post(SMS_SEND_URL, params={"api_id": api_id, "to": clean_phone},
|
||||||
|
data={"msg": message}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
if status_code == "100" and len(lines) >= 2:
|
||||||
|
return lines[1].strip(), text, "100"
|
||||||
|
return None, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS send error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
def check_sms_status(sms_id, api_id):
|
||||||
|
try:
|
||||||
|
resp = requests.post(SMS_STATUS_URL, params={"api_id": api_id, "sms_id": sms_id}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
||||||
|
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
||||||
|
return sms_status_code, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS status check error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
def fetch_balance(api_id):
|
||||||
|
try:
|
||||||
|
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
||||||
|
text = resp.text
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
if lines[0].strip() == "100" and len(lines) >= 2:
|
||||||
|
return float(lines[1].strip()), text
|
||||||
|
return None, text
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Balance fetch error: {e}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(message, chat_id):
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning(f"Telegram error: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Telegram failed: {e}")
|
||||||
|
|
||||||
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_within_work_hours(settings):
|
||||||
|
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||||
|
today_num = now_msk.weekday() + 1
|
||||||
|
allowed_days = set()
|
||||||
|
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
||||||
|
for part in str(work_days_str).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
allowed_days.add(int(part))
|
||||||
|
if today_num not in allowed_days:
|
||||||
|
return False
|
||||||
|
hour = now_msk.hour
|
||||||
|
start_h = settings.get("work_hours_start", 8)
|
||||||
|
end_h = settings.get("work_hours_end", 21)
|
||||||
|
return start_h <= hour < end_h
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_send(conn):
|
||||||
|
"""Группы с paid_storage, которым ещё не отправили уведомление."""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||||
|
og.paid_storage_at
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.delivery_status = 'paid_storage'
|
||||||
|
AND og.paid_storage_at IS NOT NULL
|
||||||
|
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
||||||
|
AND og.delivery_link IS NOT NULL
|
||||||
|
AND og.delivery_link != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.campaign_type = 'paid_storage'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
)
|
||||||
|
ORDER BY og.paid_storage_at ASC
|
||||||
|
""")
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
||||||
|
og.customer_name, og.group_key
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'paid_storage'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND (scl.created_at > NOW() - INTERVAL '%s minutes' OR scl.needs_check = true)
|
||||||
|
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_expired(conn, max_duration_min):
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at,
|
||||||
|
og.customer_name, og.group_key, og.delivery_link
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'paid_storage'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
||||||
|
ORDER BY scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def insert_sms_log(conn, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cols = ", ".join(kwargs.keys())
|
||||||
|
placeholders = ", ".join(["%s"] * len(kwargs))
|
||||||
|
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
||||||
|
log_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return log_id
|
||||||
|
|
||||||
|
def update_sms_log(conn, log_id, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(log_id)
|
||||||
|
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def update_order_group(conn, group_id, fields):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(group_id)
|
||||||
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def step_send_new(conn, settings):
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
groups = get_groups_to_send(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS")
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for group in groups:
|
||||||
|
group_id = str(group["id"])
|
||||||
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
|
delivery_link = group.get("delivery_link", "")
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = 'paid_storage'
|
||||||
|
AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND status IN ('sent', 'checking', 'delivered')
|
||||||
|
""", (group_id,))
|
||||||
|
if cur.fetchone()[0] > 0:
|
||||||
|
log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sms_text = (
|
||||||
|
f"Ваш заказ переведён на платное хранение. "
|
||||||
|
f"Стоимость: 300 ₽/день. "
|
||||||
|
f"Заберите заказ или согласуйте доставку: {delivery_link}"
|
||||||
|
)
|
||||||
|
|
||||||
|
send_phone = phone
|
||||||
|
if settings.get("test_mode", True):
|
||||||
|
send_phone = settings.get("test_phone", "79788382260")
|
||||||
|
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
||||||
|
|
||||||
|
log.info(f"Sending paid_storage SMS to {name} (orig={phone}, send={send_phone})")
|
||||||
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
|
if sms_id:
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_id=sms_id,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="sent",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
)
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "paid_storage_sending",
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: paid_storage SMS sent, sms_id={sms_id}, notification_status→paid_storage_sending")
|
||||||
|
sent_count += 1
|
||||||
|
else:
|
||||||
|
error = raw[:500] if raw else "Unknown error"
|
||||||
|
insert_sms_log(conn,
|
||||||
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="send_failed",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
error_message=error,
|
||||||
|
)
|
||||||
|
log.error(f"Group {group_id}: paid_storage SMS failed (code={code}): {error[:200]}")
|
||||||
|
update_order_group(conn, group_id, {"last_sms_error": error[:200]})
|
||||||
|
send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
def step_check_status(conn, settings):
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
|
||||||
|
sms_list = get_sms_to_check(conn, max_duration)
|
||||||
|
log.info(f"Step 2: {len(sms_list)} paid_storage SMS to check")
|
||||||
|
|
||||||
|
delivered = 0
|
||||||
|
for item in sms_list:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
sms_id = item["sms_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
|
||||||
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
||||||
|
|
||||||
|
if code == DELIVERED_CODE:
|
||||||
|
log.info(f"Group {group_id}: paid_storage SMS delivered (103)!")
|
||||||
|
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
||||||
|
update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
|
||||||
|
send_telegram(f"✅ SMS платное хранение доставлена: {name} ({phone})", tg_chat)
|
||||||
|
delivered += 1
|
||||||
|
elif code in IN_TRANSIT_CODES:
|
||||||
|
log.info(f"Group {group_id}: in transit (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
||||||
|
elif code in DELIVERY_ERROR_CODES:
|
||||||
|
log.error(f"Group {group_id}: delivery error (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}")
|
||||||
|
update_sms_log(conn, log_id, status="expired")
|
||||||
|
# Сброс для retry
|
||||||
|
update_order_group(conn, group_id, {"notification_status": "manual_required"})
|
||||||
|
elif code in LIMIT_ERROR_CODES:
|
||||||
|
log.error(f"Group {group_id}: limit exceeded (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit: {code}")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Limit exceeded (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"🚫 SMS платное хранение заблокирована (лимит): {name} ({phone})", tg_chat)
|
||||||
|
else:
|
||||||
|
log.warning(f"Group {group_id}: unknown code: {code}")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code, error_message=f"Unknown: {code}")
|
||||||
|
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
def step_handle_expired(conn, settings):
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
expired = get_sms_expired(conn, max_duration)
|
||||||
|
log.info(f"Step 3: {len(expired)} paid_storage SMS expired")
|
||||||
|
|
||||||
|
for item in expired:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: paid_storage SMS expired")
|
||||||
|
update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} min")
|
||||||
|
update_order_group(conn, group_id, {"notification_status": "paid_storage_sent"})
|
||||||
|
send_telegram(f"⚠️ SMS платное хранение не доставлена: {name} ({phone})", tg_chat)
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("Paid Storage Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(conn)
|
||||||
|
log.info(f"Settings: work={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, "
|
||||||
|
f"days={settings.get('work_days')}, test={settings.get('test_mode')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отправка только в рабочие часы
|
||||||
|
work_hours = is_within_work_hours(settings)
|
||||||
|
sent = 0
|
||||||
|
if work_hours:
|
||||||
|
sent = step_send_new(conn, settings)
|
||||||
|
else:
|
||||||
|
log.info("Outside work hours, skipping new SMS sends")
|
||||||
|
|
||||||
|
# Проверка статусов — всегда
|
||||||
|
delivered = step_check_status(conn, settings)
|
||||||
|
step_handle_expired(conn, settings)
|
||||||
|
|
||||||
|
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
||||||
|
|
||||||
|
if sent > 0 or delivered > 0:
|
||||||
|
send_telegram(
|
||||||
|
f"📦 <b>Платное хранение</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
||||||
|
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем баланс
|
||||||
|
if sent > 0:
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
balance, raw = fetch_balance(api_id)
|
||||||
|
if balance is not None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'paid_storage'",
|
||||||
|
(balance,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"Balance updated: {balance} ₽")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log.info("Paid Storage Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,586 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SuperSam — SMS Second Campaign (State Machine)
|
||||||
|
Заменяет n8n workflow "Первая проверка согласования + отправка 2й смс"
|
||||||
|
|
||||||
|
Архитектура: state machine через cron (каждые 5 мин)
|
||||||
|
Каждый запуск:
|
||||||
|
1. Отправляет вторую SMS группам, где первая SMS доставлена,
|
||||||
|
но клиент не согласовал доставку, и пришло время next_notification_check_at
|
||||||
|
2. Проверяет статус ранее отправленных вторых SMS
|
||||||
|
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||||
|
|
||||||
|
Защита от повторной отправки (ДВОЙНАЯ):
|
||||||
|
1. После отправки SMS → notification_status = 'second_sms_sending' (не 'first_sms_sent')
|
||||||
|
→ get_groups_to_send НЕ находит эту группу
|
||||||
|
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
|
||||||
|
3. Код 231/132 = лимит одинаковых → не повторять
|
||||||
|
|
||||||
|
needs_check=true — admin нажал "Проверить снова" в UI
|
||||||
|
→ скрипт проверяет даже если запись старше max_check_duration
|
||||||
|
|
||||||
|
Коды sms.ru:
|
||||||
|
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||||||
|
103 = доставлено → цель
|
||||||
|
104-108, 130-132, 230-232 = ошибки доставки / лимиты
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "10.0.4.12")
|
||||||
|
DB_PORT = os.environ.get("DB_PORT", "5432")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "postgres")
|
||||||
|
DB_USER = os.environ.get("DB_USER", "supabase_admin")
|
||||||
|
DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479")
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
||||||
|
|
||||||
|
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
||||||
|
SMS_SEND_URL = "https://sms.ru/sms/send"
|
||||||
|
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
||||||
|
|
||||||
|
LOG_FILE = "/var/log/supersam-sms-second.log"
|
||||||
|
|
||||||
|
CAMPAIGN_TYPE = "second_sms"
|
||||||
|
|
||||||
|
# Коды, которые означают "в процессе" (ждём дальше)
|
||||||
|
IN_TRANSIT_CODES = {"100", "101", "102"}
|
||||||
|
# Код доставки
|
||||||
|
DELIVERED_CODE = "103"
|
||||||
|
# Коды ошибок доставки (не временные, можно повторять отправку)
|
||||||
|
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||||||
|
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
||||||
|
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
||||||
|
|
||||||
|
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("sms_second")
|
||||||
|
|
||||||
|
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_db_conn():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||||||
|
user=DB_USER, password=DB_PASS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_settings(conn):
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
# Fallback на настройки first_sms если second_sms нет
|
||||||
|
cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"wait_between_checks_seconds": 25,
|
||||||
|
"max_check_duration_minutes": 90,
|
||||||
|
"max_attempts": 2,
|
||||||
|
"enabled": True,
|
||||||
|
"telegram_chat_id": "25164483",
|
||||||
|
"sms_api_id": SMS_API_ID,
|
||||||
|
"test_mode": True,
|
||||||
|
"test_phone": "79788382260",
|
||||||
|
"second_sms_delay_hours": 3,
|
||||||
|
"auto_manual_after_hours": 3,
|
||||||
|
}
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_phone(phone):
|
||||||
|
"""Нормализует телефон: только цифры, начинается с 7"""
|
||||||
|
clean = "".join(c for c in str(phone) if c.isdigit())
|
||||||
|
if clean.startswith("8"):
|
||||||
|
clean = "7" + clean[1:]
|
||||||
|
elif not clean.startswith("7"):
|
||||||
|
clean = "7" + clean
|
||||||
|
return clean
|
||||||
|
|
||||||
|
def send_sms(phone, message, api_id):
|
||||||
|
"""Отправляет SMS, возвращает (sms_id, raw_response, code)"""
|
||||||
|
try:
|
||||||
|
clean_phone = normalize_phone(phone)
|
||||||
|
resp = requests.post(SMS_SEND_URL, params={
|
||||||
|
"api_id": api_id,
|
||||||
|
"to": clean_phone,
|
||||||
|
}, data={"msg": message}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
||||||
|
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
|
||||||
|
if status_code == "100" and len(lines) >= 2:
|
||||||
|
sms_id = lines[1].strip()
|
||||||
|
return sms_id, text, "100"
|
||||||
|
else:
|
||||||
|
return None, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS send error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
def check_sms_status(sms_id, api_id):
|
||||||
|
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
|
||||||
|
try:
|
||||||
|
resp = requests.post(SMS_STATUS_URL, params={
|
||||||
|
"api_id": api_id,
|
||||||
|
"sms_id": sms_id,
|
||||||
|
}, timeout=30)
|
||||||
|
text = resp.text
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
status_code = lines[0].strip() if lines else ""
|
||||||
|
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
||||||
|
|
||||||
|
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
||||||
|
return sms_status_code, text, status_code
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"SMS status check error: {e}")
|
||||||
|
return None, str(e), "error"
|
||||||
|
|
||||||
|
def fetch_balance(api_id):
|
||||||
|
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
|
||||||
|
try:
|
||||||
|
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
||||||
|
text = resp.text
|
||||||
|
lines = text.strip().split("\n")
|
||||||
|
if lines[0].strip() == "100" and len(lines) >= 2:
|
||||||
|
return float(lines[1].strip()), text
|
||||||
|
return None, text
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Balance fetch error: {e}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_telegram(message, chat_id):
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
log.warning(f"Telegram error: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Telegram failed: {e}")
|
||||||
|
|
||||||
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_groups_to_send(conn):
|
||||||
|
"""Группы, где первая SMS доставлена, но клиент не согласовал,
|
||||||
|
и пришло время для второй SMS (next_notification_check_at <= NOW()).
|
||||||
|
second_sms_sent_at IS NULL — вторая SMS ещё не отправлена.
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
||||||
|
og.customer_phone_normalized, og.delivery_link, og.notification_status
|
||||||
|
FROM order_groups og
|
||||||
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
|
AND og.delivery_link IS NOT NULL
|
||||||
|
AND og.delivery_link != ''
|
||||||
|
AND COALESCE(og.notification_status, '') = 'first_sms_sent'
|
||||||
|
AND og.second_sms_sent_at IS NULL
|
||||||
|
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||||||
|
-- Нет активной второй SMS в логе
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
WHERE scl.order_group_id = og.id
|
||||||
|
AND scl.campaign_type = 'second_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||||||
|
)
|
||||||
|
ORDER BY og.created_at ASC
|
||||||
|
""")
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_to_check(conn, max_duration_min):
|
||||||
|
"""Вторые SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
||||||
|
Включает:
|
||||||
|
- Записи младше max_duration_min (обычная автопроверка)
|
||||||
|
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
||||||
|
og.customer_name, og.group_key
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'second_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
scl.created_at > NOW() - INTERVAL '%s minutes'
|
||||||
|
OR scl.needs_check = true
|
||||||
|
)
|
||||||
|
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_sms_expired(conn, max_duration_min):
|
||||||
|
"""Вторые SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены)"""
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||||||
|
scl.attempts, scl.created_at,
|
||||||
|
og.customer_name, og.group_key, og.delivery_link
|
||||||
|
FROM sms_campaign_log scl
|
||||||
|
JOIN order_groups og ON og.id = scl.order_group_id
|
||||||
|
WHERE scl.campaign_type = 'second_sms'
|
||||||
|
AND scl.status IN ('sent', 'checking')
|
||||||
|
AND scl.sms_id IS NOT NULL
|
||||||
|
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
||||||
|
ORDER BY scl.created_at ASC
|
||||||
|
""" % max_duration_min)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def insert_sms_log(conn, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cols = ", ".join(kwargs.keys())
|
||||||
|
placeholders = ", ".join(["%s"] * len(kwargs))
|
||||||
|
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
||||||
|
log_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return log_id
|
||||||
|
|
||||||
|
def update_sms_log(conn, log_id, **kwargs):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(log_id)
|
||||||
|
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def update_order_group(conn, group_id, fields):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
set_parts = []
|
||||||
|
values = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if v == "NOW()":
|
||||||
|
set_parts.append(f"{k} = NOW()")
|
||||||
|
else:
|
||||||
|
set_parts.append(f"{k} = %s")
|
||||||
|
values.append(v)
|
||||||
|
values.append(group_id)
|
||||||
|
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_within_work_hours(settings):
|
||||||
|
"""Проверка: сейчас рабочие часы.
|
||||||
|
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||||
|
"""
|
||||||
|
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||||
|
# work_days: '1,2,3,4,5' → Понедельник=1 ... Воскресенье=7
|
||||||
|
# Python weekday(): 0=Пн ... 6=Вс → конвертируем в 1-7
|
||||||
|
today_num = now_msk.weekday() + 1
|
||||||
|
allowed_days = set()
|
||||||
|
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
||||||
|
for part in str(work_days_str).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
allowed_days.add(int(part))
|
||||||
|
if today_num not in allowed_days:
|
||||||
|
return False
|
||||||
|
hour = now_msk.hour
|
||||||
|
start_h = settings.get("work_hours_start", 8)
|
||||||
|
end_h = settings.get("work_hours_end", 21)
|
||||||
|
return start_h <= hour < end_h
|
||||||
|
|
||||||
|
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def step_send_new(conn, settings):
|
||||||
|
"""Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
groups = get_groups_to_send(conn)
|
||||||
|
log.info(f"Step 1: {len(groups)} groups to send second SMS")
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for group in groups:
|
||||||
|
group_id = str(group["id"])
|
||||||
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
|
delivery_link = group.get("delivery_link", "")
|
||||||
|
|
||||||
|
# Защита: была ли уже вторая SMS этой группе за 24h
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
||||||
|
WHERE order_group_id = %s AND campaign_type = 'second_sms'
|
||||||
|
AND created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
AND status IN ('sent', 'checking', 'delivered')
|
||||||
|
""", (group_id,))
|
||||||
|
recent_count = cur.fetchone()[0]
|
||||||
|
if recent_count > 0:
|
||||||
|
log.info(f"Group {group_id}: already has recent second SMS in log, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}"
|
||||||
|
|
||||||
|
# ТЕСТОВЫЙ РЕЖИМ
|
||||||
|
send_phone = phone
|
||||||
|
if settings.get("test_mode", True):
|
||||||
|
send_phone = settings.get("test_phone", "79788382260")
|
||||||
|
log.info(f"TEST MODE: sending to {send_phone} instead of {phone}")
|
||||||
|
|
||||||
|
log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})")
|
||||||
|
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||||||
|
|
||||||
|
if sms_id:
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_id=sms_id,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="sent",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
)
|
||||||
|
# ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "second_sms_sending",
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: second SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→second_sms_sending")
|
||||||
|
sent_count += 1
|
||||||
|
else:
|
||||||
|
error = raw[:500] if raw else "Unknown error"
|
||||||
|
log_id = insert_sms_log(conn,
|
||||||
|
campaign_type=CAMPAIGN_TYPE,
|
||||||
|
order_group_id=group_id,
|
||||||
|
customer_phone=phone,
|
||||||
|
sms_text=sms_text,
|
||||||
|
status="send_failed",
|
||||||
|
sms_code=code,
|
||||||
|
attempts=1,
|
||||||
|
error_message=error,
|
||||||
|
)
|
||||||
|
log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"last_sms_error": error[:200],
|
||||||
|
})
|
||||||
|
send_telegram(f"❌ Вторая SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
||||||
|
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
def step_check_status(conn, settings):
|
||||||
|
"""Шаг 2: Проверка статуса ранее отправленных вторых SMS"""
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
auto_manual_hours = settings.get("auto_manual_after_hours", 3)
|
||||||
|
|
||||||
|
sms_list = get_sms_to_check(conn, max_duration)
|
||||||
|
log.info(f"Step 2: {len(sms_list)} second SMS to check status")
|
||||||
|
|
||||||
|
delivered = 0
|
||||||
|
for item in sms_list:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
sms_id = item["sms_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
|
||||||
|
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||||||
|
|
||||||
|
# Сбрасываем needs_check + ставим checked_at
|
||||||
|
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
||||||
|
|
||||||
|
if code == DELIVERED_CODE:
|
||||||
|
# Доставлено!
|
||||||
|
log.info(f"Group {group_id}: second SMS delivered (103)!")
|
||||||
|
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
||||||
|
|
||||||
|
next_check = (datetime.now(timezone.utc) + timedelta(hours=auto_manual_hours)).isoformat()
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "second_sms_sent",
|
||||||
|
"second_sms_sent_at": "NOW()",
|
||||||
|
"sms_attempts": attempts,
|
||||||
|
"last_sms_error": None,
|
||||||
|
"sms_sent_at": "NOW()",
|
||||||
|
"next_notification_check_at": next_check,
|
||||||
|
"status": "second_sms_sent",
|
||||||
|
})
|
||||||
|
send_telegram(f"✅ Вторая SMS доставлена: {name} ({phone})", tg_chat)
|
||||||
|
delivered += 1
|
||||||
|
|
||||||
|
elif code in IN_TRANSIT_CODES:
|
||||||
|
# В пути / в очереди — продолжаем ждать
|
||||||
|
log.info(f"Group {group_id}: second SMS in transit (code={code}), will check again next run")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
||||||
|
|
||||||
|
elif code in DELIVERY_ERROR_CODES:
|
||||||
|
# Ошибка доставки — можно повторить отправку
|
||||||
|
log.error(f"Group {group_id}: second SMS delivery error (code={code})")
|
||||||
|
update_sms_log(conn, log_id, status="error", sms_code=code,
|
||||||
|
error_message=f"Delivery error: {code}")
|
||||||
|
if attempts < max_attempts:
|
||||||
|
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}/{max_attempts})")
|
||||||
|
update_sms_log(conn, log_id, status="expired")
|
||||||
|
# Сбрасываем на first_sms_sent для повторной отправки
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "first_sms_sent",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Second SMS failed after {max_attempts} attempts (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"⚠️ Вторая SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
||||||
|
|
||||||
|
elif code in LIMIT_ERROR_CODES:
|
||||||
|
# Превышен лимит — НЕ повторять
|
||||||
|
log.error(f"Group {group_id}: second SMS limit exceeded (code={code}) — NOT retrying")
|
||||||
|
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
|
||||||
|
error_message=f"Limit exceeded: code={code}")
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Second SMS limit exceeded (code={code})",
|
||||||
|
})
|
||||||
|
send_telegram(f"🚫 Вторая SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Неизвестный код — логируем, продолжаем проверять
|
||||||
|
log.warning(f"Group {group_id}: unknown SMS code for second SMS: {code}")
|
||||||
|
update_sms_log(conn, log_id, status="checking", sms_code=code,
|
||||||
|
error_message=f"Unknown code: {code}")
|
||||||
|
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
def step_handle_expired(conn, settings):
|
||||||
|
"""Шаг 3: Обработка вторых SMS с истёкшим сроком проверки"""
|
||||||
|
max_duration = settings.get("max_check_duration_minutes", 90)
|
||||||
|
max_attempts = settings.get("max_attempts", 2)
|
||||||
|
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
expired = get_sms_expired(conn, max_duration)
|
||||||
|
log.info(f"Step 3: {len(expired)} second SMS expired (older than {max_duration} min)")
|
||||||
|
|
||||||
|
for item in expired:
|
||||||
|
log_id = item["log_id"]
|
||||||
|
group_id = str(item["order_group_id"])
|
||||||
|
name = item.get("customer_name") or item.get("group_key", "—")
|
||||||
|
phone = item.get("customer_phone", "")
|
||||||
|
attempts = item.get("attempts", 1)
|
||||||
|
|
||||||
|
log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}")
|
||||||
|
update_sms_log(conn, log_id, status="expired",
|
||||||
|
error_message=f"Not delivered in {max_duration} minutes")
|
||||||
|
|
||||||
|
if attempts < max_attempts:
|
||||||
|
# Сбрасываем на first_sms_sent для повторной отправки
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "first_sms_sent",
|
||||||
|
})
|
||||||
|
log.info(f"Group {group_id}: will retry second SMS send (attempt {attempts+1}), notification_status→first_sms_sent")
|
||||||
|
else:
|
||||||
|
# Все попытки исчерпаны → ручное управление
|
||||||
|
update_order_group(conn, group_id, {
|
||||||
|
"notification_status": "manual_required",
|
||||||
|
"last_sms_error": f"Second SMS not delivered after {max_attempts} attempts",
|
||||||
|
})
|
||||||
|
send_telegram(
|
||||||
|
f"🔧 Требуется ручное управление (2-я SMS): {name} ({phone})\n"
|
||||||
|
f"Вторая SMS не доставлена после {max_attempts} попыток",
|
||||||
|
tg_chat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("SMS Second Campaign — START")
|
||||||
|
conn = get_db_conn()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings(conn)
|
||||||
|
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||||||
|
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||||||
|
f"max_attempts={settings.get('max_attempts')}")
|
||||||
|
|
||||||
|
if not settings.get("enabled", True):
|
||||||
|
log.info("Campaign disabled, exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# State machine — каждый шаг быстрый, без blocking
|
||||||
|
# Отправка только в рабочие часы (8-21, Пн-Пт по Москве)
|
||||||
|
work_hours = is_within_work_hours(settings)
|
||||||
|
sent = 0
|
||||||
|
if work_hours:
|
||||||
|
sent = step_send_new(conn, settings)
|
||||||
|
else:
|
||||||
|
log.info("Outside work hours (8-21 MSK, Mon-Fri), skipping new SMS sends")
|
||||||
|
|
||||||
|
# Проверка статусов работает всегда — даже ночью
|
||||||
|
delivered = step_check_status(conn, settings)
|
||||||
|
step_handle_expired(conn, settings)
|
||||||
|
|
||||||
|
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
||||||
|
|
||||||
|
if sent > 0 or delivered > 0:
|
||||||
|
send_telegram(
|
||||||
|
f"📊 <b>Вторая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
||||||
|
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем баланс sms.ru
|
||||||
|
if sent > 0:
|
||||||
|
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||||
|
balance, raw = fetch_balance(api_id)
|
||||||
|
if balance is not None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'second_sms'",
|
||||||
|
(balance,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"Balance updated: {balance} ₽")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log.info("SMS Second Campaign — END")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -5,7 +5,7 @@ export const Badge = ({ children, tone = "neutral", className }) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-center text-xs font-semibold leading-tight tracking-[0.01em] shadow-sm",
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-center text-xs font-medium leading-tight tracking-[0.01em]",
|
||||||
{
|
{
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
import { Skeleton } from "../UI/Loading";
|
import { Skeleton } from "../UI/Loading";
|
||||||
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
|
import { fetchActionLogs, getActionLabel } from "../../services/supabase/actionLogService";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
@ -198,6 +199,7 @@ export const ActionLogPanel = ({ orderGroupId = null }) => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={loadLogs}
|
onClick={loadLogs}
|
||||||
disabled={loading}
|
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"
|
className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export const AdminDashboard = () => {
|
||||||
const econ = economics || {};
|
const econ = economics || {};
|
||||||
|
|
||||||
const statusPieData = (statusDist || []).map(s => ({
|
const statusPieData = (statusDist || []).map(s => ({
|
||||||
name: STATUS_LABELS[s.delivery_status] || s.delivery_status,
|
name: STATUS_LABELS[s.delivery_status] || (s.delivery_status ? `Неизвестно (${s.delivery_status})` : 'Неизвестно'),
|
||||||
value: s.count,
|
value: s.count,
|
||||||
status: s.delivery_status,
|
status: s.delivery_status,
|
||||||
})).filter(d => d.value > 0);
|
})).filter(d => d.value > 0);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Panel } from '../UI/Panel';
|
import { Panel } from '../UI/Panel';
|
||||||
import { Badge } from '../UI/Badge';
|
import { Badge } from '../UI/Badge';
|
||||||
|
import { Button } from '../UI/Button';
|
||||||
import { Select } from '../UI/Select';
|
import { Select } from '../UI/Select';
|
||||||
import { Skeleton } from '../UI/Loading';
|
import { Skeleton } from '../UI/Loading';
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
|
|
@ -235,27 +236,27 @@ export default function ErrorLogPanel() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<button onClick={fetchErrors} style={btnBase} title="Обновить">↻</button>
|
<button type="button" onClick={fetchErrors} style={btnBase} title="Обновить">↻</button>
|
||||||
<button onClick={handleCopyAll} disabled={errors.length === 0} style={{ ...btnAccent, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
<button type="button" onClick={handleCopyAll} disabled={errors.length === 0} style={{ ...btnAccent, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
||||||
📋 Копировать всё
|
📋 Копировать всё
|
||||||
</button>
|
</button>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<button onClick={handleCopySelected} style={btnAccent}>
|
<button type="button" onClick={handleCopySelected} style={btnAccent}>
|
||||||
📋 Копировать выбр.
|
📋 Копировать выбр.
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={downloadJSON} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
<button type="button" onClick={downloadJSON} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
||||||
⬇ JSON
|
⬇ JSON
|
||||||
</button>
|
</button>
|
||||||
<button onClick={downloadCSV} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
<button type="button" onClick={downloadCSV} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
||||||
⬇ CSV
|
⬇ CSV
|
||||||
</button>
|
</button>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<button onClick={handleDeleteSelected} disabled={deleting} style={{ ...btnDanger, opacity: deleting ? 0.5 : 1 }}>
|
<button type="button" onClick={handleDeleteSelected} disabled={deleting} style={{ ...btnDanger, opacity: deleting ? 0.5 : 1 }}>
|
||||||
🗑 Удалить выбр.
|
🗑 Удалить выбр.
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleDeleteAll} disabled={deleting || errors.length === 0} style={{ ...btnDanger, opacity: deleting || errors.length === 0 ? 0.4 : 1 }}>
|
<button type="button" onClick={handleDeleteAll} disabled={deleting || errors.length === 0} style={{ ...btnDanger, opacity: deleting || errors.length === 0 ? 0.4 : 1 }}>
|
||||||
🗑 Удалить все
|
🗑 Удалить все
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -382,7 +383,7 @@ export default function ErrorLogPanel() {
|
||||||
{err.user_id && <div style={{ gridColumn: '1 / -1' }}><strong>User ID:</strong> {err.user_id}</div>}
|
{err.user_id && <div style={{ gridColumn: '1 / -1' }}><strong>User ID:</strong> {err.user_id}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => handleDeleteOne(err.id)} disabled={deleting} style={{ ...btnDanger, fontSize: '0.8rem' }}>
|
<button type="button" onClick={() => handleDeleteOne(err.id)} disabled={deleting} style={{ ...btnDanger, fontSize: '0.8rem' }}>
|
||||||
Удалить запись
|
Удалить запись
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,759 @@
|
||||||
|
/**
|
||||||
|
* @file SmsCampaignPanel.jsx
|
||||||
|
* @description SMS Campaign management for mega_admin.
|
||||||
|
* Campaign selector cards + settings + log table + "Проверить снова" button
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||||||
|
|
||||||
|
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||||
|
const CAMPAIGNS = [
|
||||||
|
{ key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" },
|
||||||
|
{ key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" },
|
||||||
|
{ key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" },
|
||||||
|
{ key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
|
||||||
|
|
||||||
|
// ── Days of week ─────────────────────────────────────────────────────────────
|
||||||
|
const DAYS = [
|
||||||
|
{ num: 1, short: "Пн" },
|
||||||
|
{ num: 2, short: "Вт" },
|
||||||
|
{ num: 3, short: "Ср" },
|
||||||
|
{ num: 4, short: "Чт" },
|
||||||
|
{ num: 5, short: "Пт" },
|
||||||
|
{ num: 6, short: "Сб" },
|
||||||
|
{ num: 7, short: "Вс" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Status labels ────────────────────────────────────────────────────────────
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
sent: "Отправлено",
|
||||||
|
checking: "Проверяется",
|
||||||
|
delivered: "Доставлено",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
error: "Ошибка доставки",
|
||||||
|
expired: "Истёк срок",
|
||||||
|
limit_exceeded: "Лимит превышен",
|
||||||
|
manual_override: "Ручной режим",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TONES = {
|
||||||
|
sent: "info",
|
||||||
|
checking: "neutral",
|
||||||
|
delivered: "accent",
|
||||||
|
send_failed: "danger",
|
||||||
|
error: "danger",
|
||||||
|
expired: "warning",
|
||||||
|
limit_exceeded: "danger",
|
||||||
|
manual_override: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── SMS code labels ──────────────────────────────────────────────────────────
|
||||||
|
const SMS_CODE_LABELS = {
|
||||||
|
"100": "В очереди", "101": "Оператору", "102": "В пути",
|
||||||
|
"103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором",
|
||||||
|
"106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено",
|
||||||
|
"130": "Лимит на номер/день", "131": "Лимит одинаковых/мин",
|
||||||
|
"132": "Лимит одинаковых/день", "200": "Неправильный api_id",
|
||||||
|
"201": "Недостаточно средств", "202": "Неправильный получатель",
|
||||||
|
"230": "Общий лимит/день", "231": "Лимит одинаковых/мин",
|
||||||
|
"232": "Лимит одинаковых/день",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch { return ts; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtPhone = (phone) => {
|
||||||
|
if (!phone) return "—";
|
||||||
|
const digits = String(phone).replace(/\D/g, "");
|
||||||
|
if (digits.length === 11 && digits.startsWith("7")) {
|
||||||
|
return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`;
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtElapsed = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
const diff = Date.now() - new Date(ts).getTime();
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
if (m < 1) return "только что";
|
||||||
|
if (m < 60) return `${m} мин назад`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rest = m % 60;
|
||||||
|
return `${h}ч ${rest}м назад`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseWorkDays = (str) => {
|
||||||
|
if (!str) return new Set([1, 2, 3, 4, 5]);
|
||||||
|
return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7));
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeWorkDays = (set) => [...set].sort().join(",");
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export const SmsCampaignPanel = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeCampaign, setActiveCampaign] = useState("first_sms");
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [manualGroups, setManualGroups] = useState([]);
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
|
const [allSettings, setAllSettings] = useState({});
|
||||||
|
const [campaignCounts, setCampaignCounts] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [filter, setFilter] = useState("all");
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
|
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||||
|
const [checkingIds, setCheckingIds] = useState(new Set());
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [togglingCampaign, setTogglingCampaign] = useState(null);
|
||||||
|
const refreshTimer = useRef(null);
|
||||||
|
|
||||||
|
const handleOpenGroup = useCallback((groupId) => {
|
||||||
|
if (groupId) navigate("/dashboard/group/" + groupId);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// ── Load data ──────────────────────────────────────────────────────────────
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Load all settings for cards
|
||||||
|
const { data: allSettingsData, error: allErr } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.select("*");
|
||||||
|
if (allErr) throw allErr;
|
||||||
|
const settingsMap = {};
|
||||||
|
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
||||||
|
setAllSettings(settingsMap);
|
||||||
|
|
||||||
|
// Load counts per campaign (for cards)
|
||||||
|
const { data: countsData, error: countsErr } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("campaign_type, status");
|
||||||
|
if (countsErr) throw countsErr;
|
||||||
|
const counts = {};
|
||||||
|
(countsData || []).forEach(r => {
|
||||||
|
if (!counts[r.campaign_type]) counts[r.campaign_type] = { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||||||
|
counts[r.campaign_type].total++;
|
||||||
|
if (r.status === "delivered") counts[r.campaign_type].delivered++;
|
||||||
|
if (r.status === "sent" || r.status === "checking") counts[r.campaign_type].sent++;
|
||||||
|
if (["send_failed", "error", "limit_exceeded"].includes(r.status)) counts[r.campaign_type].errors++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also count manual_required groups for the manual card
|
||||||
|
const { count: manualCount, error: manualCountErr } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.eq("notification_status", "manual_required");
|
||||||
|
if (!manualCountErr && manualCount != null) {
|
||||||
|
counts["manual"] = counts["manual"] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||||||
|
counts["manual"].total = manualCount;
|
||||||
|
}
|
||||||
|
setCampaignCounts(counts);
|
||||||
|
|
||||||
|
// For "manual" campaign: load order_groups with manual_required status
|
||||||
|
if (activeCampaign === "manual") {
|
||||||
|
const { data: manualData, error: manualErr } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id, customer_name, customer_phone, notification_status, first_sms_sent_at, second_sms_sent_at, next_notification_check_at, sms_attempts, last_sms_error, delivery_status, delivery_link, created_at, updated_at")
|
||||||
|
.eq("notification_status", "manual_required")
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
if (manualErr) throw manualErr;
|
||||||
|
setManualGroups(manualData || []);
|
||||||
|
setLogs([]); // No SMS logs for manual campaign
|
||||||
|
} else {
|
||||||
|
setManualGroups([]);
|
||||||
|
// Load logs for active campaign
|
||||||
|
let query = supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("*")
|
||||||
|
.eq("campaign_type", activeCampaign)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
if (filter !== "all") query = query.eq("status", filter);
|
||||||
|
const { data: logData, error: logError } = await query;
|
||||||
|
if (logError) throw logError;
|
||||||
|
setLogs(logData || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load settings for active campaign
|
||||||
|
if (HAS_SETTINGS.includes(activeCampaign)) {
|
||||||
|
setSettings(settingsMap[activeCampaign] || null);
|
||||||
|
} else {
|
||||||
|
setSettings(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || String(e));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeCampaign, filter]);
|
||||||
|
|
||||||
|
useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
refreshTimer.current = setInterval(() => loadData(), 30000);
|
||||||
|
return () => clearInterval(refreshTimer.current);
|
||||||
|
}
|
||||||
|
}, [autoRefresh, loadData]);
|
||||||
|
|
||||||
|
// ── Save settings ──────────────────────────────────────────────────────────
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
const { id, ...updates } = settings;
|
||||||
|
updates.updated_at = new Date().toISOString();
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setSettingsSaved(true);
|
||||||
|
setTimeout(() => setSettingsSaved(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка сохранения: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = (key, value) => {
|
||||||
|
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Quick toggle campaign on/off from card ────────────────────────────────
|
||||||
|
const toggleCampaignEnabled = async (campaignKey) => {
|
||||||
|
const s = allSettings[campaignKey];
|
||||||
|
if (!s) return;
|
||||||
|
setTogglingCampaign(campaignKey);
|
||||||
|
const newVal = !(s.enabled ?? true);
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("sms_campaign_settings")
|
||||||
|
.update({ enabled: newVal, updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", s.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
// Update local state
|
||||||
|
setAllSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[campaignKey]: { ...prev[campaignKey], enabled: newVal }
|
||||||
|
}));
|
||||||
|
if (campaignKey === activeCampaign) {
|
||||||
|
setSettings(prev => prev ? { ...prev, enabled: newVal } : prev);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setTogglingCampaign(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWorkDay = (dayNum) => {
|
||||||
|
const current = parseWorkDays(settings?.work_days);
|
||||||
|
if (current.has(dayNum)) current.delete(dayNum);
|
||||||
|
else current.add(dayNum);
|
||||||
|
updateSetting("work_days", serializeWorkDays(current));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Recheck ────────────────────────────────────────────────────────────────
|
||||||
|
const handleRecheck = async (logId) => {
|
||||||
|
setCheckingIds(prev => new Set([...prev, logId]));
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.update({ needs_check: true, updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", logId);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setLogs(prev => prev.map(l => l.id === logId ? { ...l, needs_check: true } : l));
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ошибка: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; });
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||||
|
const stats = logs.reduce((acc, log) => {
|
||||||
|
acc[log.status] = (acc[log.status] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const showSettings = HAS_SETTINGS.includes(activeCampaign);
|
||||||
|
const isManualCampaign = activeCampaign === "manual";
|
||||||
|
const isPaidStorageCampaign = activeCampaign === "paid_storage";
|
||||||
|
const showSmsFields = !isManualCampaign;
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
|
if (isLoading && logs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка…</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
|
||||||
|
{/* Balance banner */}
|
||||||
|
{allSettings.first_sms?.last_balance != null && (
|
||||||
|
<Panel className="p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">💰</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
||||||
|
<div className="text-lg font-bold text-[var(--color-text)]">
|
||||||
|
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Campaign cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{CAMPAIGNS.map(c => {
|
||||||
|
const isActive = activeCampaign === c.key;
|
||||||
|
const s = allSettings[c.key];
|
||||||
|
const isEnabled = s?.enabled ?? true;
|
||||||
|
const isTest = s?.test_mode ?? true;
|
||||||
|
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||||||
|
const hasS = HAS_SETTINGS.includes(c.key);
|
||||||
|
const isToggling = togglingCampaign === c.key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.key}
|
||||||
|
className={`rounded-2xl border p-4 transition cursor-pointer ${
|
||||||
|
isActive
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
onClick={() => { setActiveCampaign(c.key); setFilter("all"); }}
|
||||||
|
>
|
||||||
|
{/* Header: icon + toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl">{c.icon}</span>
|
||||||
|
{hasS && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleCampaignEnabled(c.key); }}
|
||||||
|
disabled={isToggling}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition ${isEnabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition ${isEnabled ? "left-[20px]" : "left-0.5"}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="mt-2 text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
{hasS && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||||
|
isEnabled
|
||||||
|
? (isTest ? "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]" : "bg-[rgba(34,197,94,0.15)] text-[#22c55e]")
|
||||||
|
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
||||||
|
}`}>
|
||||||
|
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
{hasS && cnt.total > 0 && (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-1 text-center">
|
||||||
|
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">Всего</div>
|
||||||
|
<div className="text-xs font-bold text-[var(--color-text)]">{cnt.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">Доставл.</div>
|
||||||
|
<div className="text-xs font-bold text-[#22c55e]">{cnt.delivered}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||||||
|
<div className="text-[10px] text-[var(--color-text-muted)]">Ошибок</div>
|
||||||
|
<div className="text-xs font-bold text-[#ef4444]">{cnt.errors}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasS && (
|
||||||
|
<div className="mt-2 text-[11px] text-[var(--color-text-muted)] leading-snug">В разработке</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Panel className="p-3">
|
||||||
|
<div className="text-xs text-[var(--color-danger)]">{error}</div>
|
||||||
|
<button onClick={() => { setError(null); loadData(); }} className="mt-2 text-xs text-[var(--color-accent)]">Повторить</button>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
||||||
|
<SmsCampaignStats campaignType={activeCampaign} />
|
||||||
|
|
||||||
|
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
||||||
|
{showSettings && settings && (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label}
|
||||||
|
</h3>
|
||||||
|
{settingsSaved && <span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test / Production toggle — только для SMS-кампаний */}
|
||||||
|
{!isManualCampaign && (
|
||||||
|
<div className={`mb-4 rounded-xl border p-3 ${settings.test_mode ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]" : "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"}`}>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
{settings.test_mode ? "🧪 Тестовый режим" : "🚀 Боевой режим"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{settings.test_mode
|
||||||
|
? `SMS только на: ${settings.test_phone || "—"}`
|
||||||
|
: "SMS реальным клиентам из базы"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateSetting("test_mode", !settings.test_mode)}
|
||||||
|
className={`relative h-7 w-12 rounded-full transition ${settings.test_mode ? "bg-[var(--color-warning)]" : "bg-[var(--color-accent)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.test_mode ? "left-0.5" : "left-[22px]"}`} />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{settings.test_mode && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── SMS text template ──────────────────────────────────────────── */}
|
||||||
|
{!isManualCampaign && (
|
||||||
|
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
|
||||||
|
<textarea
|
||||||
|
value={settings.sms_text_template || ""}
|
||||||
|
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
|
||||||
|
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Интервалы ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">
|
||||||
|
{isManualCampaign ? "⚙️ Настройки" : "📊 Интервалы и попытки"}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{!isManualCampaign && (
|
||||||
|
<>
|
||||||
|
<SettingField label="Пауза между проверками (сек)" value={settings.wait_between_checks_seconds} onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} />
|
||||||
|
<SettingField label="Макс. время ожидания (мин)" value={settings.max_check_duration_minutes} onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)} />
|
||||||
|
<SettingField label="Макс. попыток отправки" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 2)} />
|
||||||
|
{!isPaidStorageCampaign && (
|
||||||
|
<SettingField label="Вторая SMS через (часов)" value={settings.second_sms_delay_hours} onChange={(v) => updateSetting("second_sms_delay_hours", parseInt(v) || 3)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
|
||||||
|
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Время работы ────────────────────────────────────────────── */}
|
||||||
|
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">
|
||||||
|
{isManualCampaign ? "⏰ Время проверки" : "⏰ Время отправки SMS"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Часы */}
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={settings.work_hours_start ?? 8}
|
||||||
|
onChange={(e) => updateSetting("work_hours_start", parseInt(e.target.value))}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||||
|
<select
|
||||||
|
value={settings.work_hours_end ?? 21}
|
||||||
|
onChange={(e) => updateSetting("work_hours_end", parseInt(e.target.value))}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Дни недели — таблетки */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS.map(d => {
|
||||||
|
const activeDays = parseWorkDays(settings.work_days);
|
||||||
|
const isOn = activeDays.has(d.num);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.num}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWorkDay(d.num)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||||||
|
isOn
|
||||||
|
? "bg-[var(--color-accent)] text-white"
|
||||||
|
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.short}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Проверка статусов работает круглосуточно. Отправка — только в выбранные часы и дни.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Вкл/выкл + сохранить ──────────────────────────────────────── */}
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
|
||||||
|
className={`relative h-7 w-12 rounded-full transition ${settings.enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.enabled ? "left-[22px]" : "left-0.5"}`} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-medium text-[var(--color-text)]">
|
||||||
|
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
disabled={savingSettings}
|
||||||
|
className="ml-auto rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingSettings ? "Сохранение…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
||||||
|
{!showSettings && (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Manual campaign: groups table ─────────────────────────────────── */}
|
||||||
|
{isManualCampaign ? (
|
||||||
|
<>
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-[var(--color-text-muted)]">В ручном управлении: {manualGroups.length}</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[800px]">
|
||||||
|
<div className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
|
||||||
|
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">1-я SMS</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">2-я SMS</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||||||
|
</div>
|
||||||
|
{manualGroups.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
|
||||||
|
) : (
|
||||||
|
manualGroups.map((group) => (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => handleOpenGroup(group.id)}
|
||||||
|
className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||||
|
<div className="font-medium">{group.customer_name || "—"}</div>
|
||||||
|
{group.last_sms_error && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{group.last_sms_error.slice(0, 60)}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{fmtPhone(group.customer_phone)}</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{group.first_sms_sent_at ? fmtTime(group.first_sms_sent_at) : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{group.second_sms_sent_at ? fmtTime(group.second_sms_sent_at) : <span className="text-[var(--color-danger)]">не отправлена</span>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{fmtTime(group.updated_at)}
|
||||||
|
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Stats ─────────────────────────────────────────────────────────── */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||||||
|
const count = stats[status] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <Badge key={status} tone={STATUS_TONES[status] || "neutral"}>{label}: {count}</Badge>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* ── Filter + auto-refresh ─────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<FilterButton active={filter === "all"} onClick={() => setFilter("all")}>Все ({logs.length})</FilterButton>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||||||
|
const count = stats[status] || 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <FilterButton key={status} active={filter === status} onClick={() => setFilter(status)}>{label} ({count})</FilterButton>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} className="h-3.5 w-3.5 rounded border-[var(--color-border)]" />
|
||||||
|
Авто-обновление (30с)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Log table ──────────────────────────────────────────────────────── */}
|
||||||
|
<Panel className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[900px]">
|
||||||
|
<div className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
|
||||||
|
<div className="px-3 py-1.5 font-medium">Телефон</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">SMS ID</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Код</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Поп.</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Создано</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Действие</div>
|
||||||
|
</div>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
|
||||||
|
) : (
|
||||||
|
logs.map((entry) => {
|
||||||
|
const canRecheck = entry.status === "sent" || entry.status === "checking";
|
||||||
|
const isChecking = checkingIds.has(entry.id);
|
||||||
|
const isTestMode = settings?.test_mode;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => handleOpenGroup(entry.order_group_id)}
|
||||||
|
className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||||
|
{fmtPhone(entry.customer_phone)}
|
||||||
|
{isTestMode && <div className="text-[10px] text-[var(--color-warning)]">🧪 на тест. номер</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.sms_id || "—"}</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>{STATUS_LABELS[entry.status] || entry.status}</Badge>
|
||||||
|
{entry.needs_check && <div className="mt-0.5 text-[10px] text-[var(--color-accent)]">⟳ в очереди</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]" title={SMS_CODE_LABELS[entry.sms_code] || ""}>
|
||||||
|
{entry.sms_code || "—"}
|
||||||
|
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && <div className="text-[10px]">{SMS_CODE_LABELS[entry.sms_code]}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.attempts || 0}</div>
|
||||||
|
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||||||
|
{fmtTime(entry.created_at)}
|
||||||
|
<div className="text-[10px]">{fmtElapsed(entry.created_at)}</div>
|
||||||
|
{entry.error_message && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{entry.error_message.slice(0, 80)}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{canRecheck ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRecheck(entry.id)}
|
||||||
|
disabled={isChecking || entry.needs_check}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] px-2 py-1 text-[11px] font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isChecking ? "…" : entry.needs_check ? "⟳ В очереди" : "↻ Проверить"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">→ к доставке</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
|
||||||
|
↻ Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const SettingField = ({ label, value, onChange }) => (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FilterButton = ({ active, onClick, children }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||||||
|
active
|
||||||
|
? "bg-[var(--color-accent)] text-white"
|
||||||
|
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* @file SmsCampaignStats.jsx
|
||||||
|
* @description SMS Campaign statistics with charts:
|
||||||
|
* - KPI cards (sent, delivered, in transit, errors)
|
||||||
|
* - Daily trend (sent vs delivered, last 14 days)
|
||||||
|
* - Status donut chart
|
||||||
|
* - Delivery rate
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
|
PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
delivered: "#22c55e",
|
||||||
|
sent: "#3b82f6",
|
||||||
|
checking: "#94a3b8",
|
||||||
|
expired: "#eab308",
|
||||||
|
send_failed: "#ef4444",
|
||||||
|
error: "#ef4444",
|
||||||
|
limit_exceeded: "#f97316",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
delivered: "Доставлено",
|
||||||
|
sent: "Отправлено",
|
||||||
|
checking: "Проверяется",
|
||||||
|
expired: "Истёк срок",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
error: "Ошибка доставки",
|
||||||
|
limit_exceeded: "Лимит",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SmsCampaignStats = ({ campaignType }) => {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [dailyData, setDailyData] = useState([]);
|
||||||
|
const [statusData, setStatusData] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [period, setPeriod] = useState("14d");
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14;
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - days);
|
||||||
|
const startDateISO = startDate.toISOString();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("status, sms_code, attempts, created_at, campaign_type")
|
||||||
|
.eq("campaign_type", campaignType)
|
||||||
|
.gte("created_at", startDateISO)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// KPI
|
||||||
|
const total = data.length;
|
||||||
|
const delivered = data.filter(d => d.status === "delivered").length;
|
||||||
|
const inTransit = data.filter(d => d.status === "sent" || d.status === "checking").length;
|
||||||
|
const errors = data.filter(d => ["send_failed", "error", "limit_exceeded"].includes(d.status)).length;
|
||||||
|
const expired = data.filter(d => d.status === "expired").length;
|
||||||
|
const deliveryRate = total > 0 ? Math.round((delivered / total) * 100) : 0;
|
||||||
|
|
||||||
|
setStats({ total, delivered, inTransit, errors, expired, deliveryRate });
|
||||||
|
|
||||||
|
// Daily data
|
||||||
|
const byDay = {};
|
||||||
|
data.forEach(d => {
|
||||||
|
const day = new Date(d.created_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||||
|
if (!byDay[day]) byDay[day] = { date: day, sent: 0, delivered: 0, errors: 0 };
|
||||||
|
byDay[day].sent++;
|
||||||
|
if (d.status === "delivered") byDay[day].delivered++;
|
||||||
|
if (["send_failed", "error", "limit_exceeded"].includes(d.status)) byDay[day].errors++;
|
||||||
|
});
|
||||||
|
setDailyData(Object.values(byDay));
|
||||||
|
|
||||||
|
// Status pie
|
||||||
|
const byStatus = {};
|
||||||
|
data.forEach(d => {
|
||||||
|
byStatus[d.status] = (byStatus[d.status] || 0) + 1;
|
||||||
|
});
|
||||||
|
setStatusData(Object.entries(byStatus).map(([name, value]) => ({
|
||||||
|
name: STATUS_LABELS[name] || name,
|
||||||
|
value,
|
||||||
|
color: STATUS_COLORS[name] || "#94a3b8",
|
||||||
|
})));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Stats error:", e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [campaignType, period]);
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(); }, [loadStats]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка статистики…</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats || stats.total === 0) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">Нет данных для статистики</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* ── Period selector ──────────────────────────────────────────────── */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ key: "7d", label: "7 дней" },
|
||||||
|
{ key: "14d", label: "14 дней" },
|
||||||
|
{ key: "30d", label: "30 дней" },
|
||||||
|
{ key: "all", label: "Всё время" },
|
||||||
|
].map(p => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => setPeriod(p.key)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||||||
|
period === p.key
|
||||||
|
? "bg-[var(--color-accent)] text-white"
|
||||||
|
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI cards ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<KpiCard label="Всего" value={stats.total} color="var(--color-text)" />
|
||||||
|
<KpiCard label="Доставлено" value={stats.delivered} color="#22c55e" />
|
||||||
|
<KpiCard label="В пути" value={stats.inTransit} color="#3b82f6" />
|
||||||
|
<KpiCard label="Истёк срок" value={stats.expired} color="#eab308" />
|
||||||
|
<KpiCard label="Ошибки" value={stats.errors} color="#ef4444" />
|
||||||
|
<KpiCard label="Конверсия" value={`${stats.deliveryRate}%`} color="var(--color-accent)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Charts ───────────────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
{/* Daily trend */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📈 Отправки по дням</div>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={dailyData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-surface-strong)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||||
|
<Bar dataKey="sent" name="Отправлено" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="errors" name="Ошибки" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Status donut */}
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">🍩 По статусам</div>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={85}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-surface-strong)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Delivery rate trend ──────────────────────────────────────────── */}
|
||||||
|
{dailyData.length > 1 && (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📊 Конверсия доставки по дням</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart
|
||||||
|
data={dailyData.map(d => ({
|
||||||
|
date: d.date,
|
||||||
|
rate: d.sent > 0 ? Math.round((d.delivered / d.sent) * 100) : 0,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} domain={[0, 100]} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-surface-strong)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(v) => [`${v}%`, "Конверсия"]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="rate"
|
||||||
|
name="Конверсия %"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: "var(--color-accent)" }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── KPI Card ─────────────────────────────────────────────────────────────────
|
||||||
|
const KpiCard = ({ label, value, color }) => (
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<div className="text-[10px] font-medium text-[var(--color-text-muted)]">{label}</div>
|
||||||
|
<div className="mt-1 text-lg font-bold" style={{ color }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -112,11 +112,10 @@ export const StopWordsPanel = () => {
|
||||||
disabled={isSavingScope}
|
disabled={isSavingScope}
|
||||||
onClick={() => handleScopeChange("everywhere")}
|
onClick={() => handleScopeChange("everywhere")}
|
||||||
className={[
|
className={[
|
||||||
"rounded-full border px-4 py-2 text-sm font-medium transition",
|
"rounded-full border px-4 py-2 text-sm font-medium transition disabled:opacity-50",
|
||||||
scope === "everywhere"
|
scope === "everywhere"
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||||
: "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)]",
|
||||||
isSavingScope ? "opacity-50" : "",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
Везде
|
Везде
|
||||||
|
|
@ -126,11 +125,10 @@ export const StopWordsPanel = () => {
|
||||||
disabled={isSavingScope}
|
disabled={isSavingScope}
|
||||||
onClick={() => handleScopeChange("client_only")}
|
onClick={() => handleScopeChange("client_only")}
|
||||||
className={[
|
className={[
|
||||||
"rounded-full border px-4 py-2 text-sm font-medium transition",
|
"rounded-full border px-4 py-2 text-sm font-medium transition disabled:opacity-50",
|
||||||
scope === "client_only"
|
scope === "client_only"
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
? "border-[var(--color-accent)] bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||||
: "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)]",
|
||||||
isSavingScope ? "opacity-50" : "",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
Только карточка клиента
|
Только карточка клиента
|
||||||
|
|
@ -153,9 +151,14 @@ export const StopWordsPanel = () => {
|
||||||
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)]"
|
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}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleAdd} disabled={!newWord.trim()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newWord.trim()}
|
||||||
|
className="rounded-2xl bg-[var(--color-accent)] px-4 py-2.5 text-sm font-medium text-[var(--color-accent-contrast)] transition hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
|
||||||
const CATEGORY_OPTIONS = [
|
const CATEGORY_OPTIONS = [
|
||||||
|
|
@ -110,7 +111,7 @@ export const SuggestionsPanel = () => {
|
||||||
className={[
|
className={[
|
||||||
"rounded-xl border px-3 py-1.5 text-xs font-medium transition",
|
"rounded-xl border px-3 py-1.5 text-xs font-medium transition",
|
||||||
newCategory === cat.value
|
newCategory === cat.value
|
||||||
? "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)]"
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]"
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Panel } from '../UI/Panel';
|
import { Panel } from '../UI/Panel';
|
||||||
import { Badge } from '../UI/Badge';
|
import { Badge } from '../UI/Badge';
|
||||||
|
import { Button } from '../UI/Button';
|
||||||
import { Input } from '../UI/Input';
|
import { Input } from '../UI/Input';
|
||||||
import { Skeleton } from '../UI/Loading';
|
import { Skeleton } from '../UI/Loading';
|
||||||
|
|
||||||
|
|
@ -73,7 +74,7 @@ function RoleDropdown({ value, onChange }) {
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className={[
|
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)]',
|
'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)]',
|
'border-[var(--color-border)] bg-[var(--color-surface-strong)]',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{value ? (
|
{value ? (
|
||||||
|
|
@ -93,7 +94,7 @@ function RoleDropdown({ value, onChange }) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => pick(r)}
|
onClick={() => pick(r)}
|
||||||
className={[
|
className={[
|
||||||
'flex w-full items-center justify-between px-4 py-3 text-left text-sm transition',
|
'flex w-full items-center justify-between px-4 py-2.5 text-left text-sm transition',
|
||||||
sel ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : 'text-[var(--color-text)] hover:bg-[var(--color-surface)]',
|
sel ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : 'text-[var(--color-text)] hover:bg-[var(--color-surface)]',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|
@ -295,6 +296,7 @@ export default function UserManagementPanel() {
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap mb-4">
|
<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>
|
<span className="text-sm text-[var(--color-text-muted)]">{users.length} пользователей</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => { setShowAddForm(true); setAddError(null); }}
|
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"
|
className="rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white hover:opacity-90 transition"
|
||||||
>
|
>
|
||||||
|
|
@ -355,10 +357,12 @@ export default function UserManagementPanel() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={cancelEdit}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={saveEdit}
|
onClick={saveEdit}
|
||||||
disabled={saving}
|
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"
|
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"
|
||||||
|
|
@ -380,16 +384,18 @@ export default function UserManagementPanel() {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge tone={ROLE_TONES[rn] || 'neutral'}>{ROLE_LABELS[rn] || rn}</Badge>
|
<Badge tone={ROLE_TONES[rn] || 'neutral'}>{ROLE_LABELS[rn] || rn}</Badge>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => startEdit(user)}
|
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"
|
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>
|
>Изменить</button>
|
||||||
{deleteConfirmId === user.id ? (
|
{deleteConfirmId === user.id ? (
|
||||||
<div className="flex gap-1">
|
<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 type="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>
|
<button type="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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setDeleteConfirmId(user.id)}
|
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"
|
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>
|
>Удалить</button>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ export const DeliveryChoiceFlow = ({
|
||||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
|
||||||
{deliveryType === "pickup" ? "Выберите время самовывоза" : "Выберите время доставки"}
|
{deliveryType === "pickup" ? "Выберите время самовывоза" : "Выберите время доставки"}
|
||||||
</h1>
|
</h1>
|
||||||
<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}. Выберите удобную половину дня для {typeLabel}.
|
{invitationReference}. Выберите удобную половину дня для {typeLabel}.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { getInvitationReferenceLabel } from "./invitationReference";
|
import { getInvitationReferenceLabel } from "./invitationReference";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
@ -100,8 +101,8 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-3 border shadow-soft p-5 sm:p-6">
|
<Panel className="space-y-3 border shadow-soft p-5 sm:p-6">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
className="flex w-full items-center justify-between text-left"
|
className="flex w-full items-center justify-between text-left"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
|
|
@ -121,7 +122,7 @@ export const OrderCompositionPanel = ({ invitation = {} }) => {
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{products.map((product, idx) => (
|
{products.map((product, idx) => (
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
||||||
|
|
||||||
const slots = [];
|
const slots = [];
|
||||||
|
|
||||||
if (isTodayWorkday && hour < 12) {
|
if (isTodayWorkday) {
|
||||||
|
if (hour < 12) {
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${todayKey}-first`,
|
id: `pickup-${todayKey}-first`,
|
||||||
date: todayKey,
|
date: todayKey,
|
||||||
|
|
@ -66,6 +67,14 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
||||||
pickupType: "today",
|
pickupType: "today",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
slots.push({
|
||||||
|
id: `pickup-${todayKey}-second`,
|
||||||
|
date: todayKey,
|
||||||
|
time: "Вторая половина дня",
|
||||||
|
label: "Сегодня",
|
||||||
|
pickupType: "today",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
const tomorrow = addDaysKey(todayKey, 1);
|
||||||
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
|
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,15 @@ const ProblemReasonModal = ({ onSelect, onCancel }) => (
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Укажите причину возникшей проблемы с доставкой.</p>
|
<p className="text-sm text-[var(--color-text-muted)]">Укажите причину возникшей проблемы с доставкой.</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{PROBLEM_REASONS.map((reason) => (
|
{PROBLEM_REASONS.map((reason) => (
|
||||||
<button
|
<Button
|
||||||
key={reason.value}
|
key={reason.value}
|
||||||
type="button"
|
variant="secondary"
|
||||||
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)]"
|
className="w-full rounded-[16px] p-3 text-left"
|
||||||
onClick={() => onSelect(reason.value, reason.label)}
|
onClick={() => onSelect(reason.value, reason.label)}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{reason.label}</span>
|
<span className="font-medium">{reason.label}</span>
|
||||||
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">{reason.description}</p>
|
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">{reason.description}</p>
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
parseGroupDate,
|
parseGroupDate,
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
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";
|
||||||
|
|
@ -247,31 +248,35 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
{/* Date pills */}
|
{/* Date pills */}
|
||||||
{sortedDeliveryDates.length > 0 && (
|
{sortedDeliveryDates.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
|
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
|
||||||
className={[
|
className={[
|
||||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
"rounded-full px-3 py-1.5 text-xs font-medium",
|
||||||
!filters.selectedDate
|
!filters.selectedDate
|
||||||
? "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)]",
|
: "text-[var(--color-text-muted)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
Все даты
|
Все даты
|
||||||
</button>
|
</Button>
|
||||||
{sortedDeliveryDates.map((date) => {
|
{sortedDeliveryDates.map((date) => {
|
||||||
const count = dateDeliveryMap.get(date) || 0;
|
const count = dateDeliveryMap.get(date) || 0;
|
||||||
const selected = isDateSelected(date);
|
const selected = isDateSelected(date);
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={date}
|
key={date}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
|
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
|
||||||
className={[
|
className={[
|
||||||
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
||||||
selected
|
selected
|
||||||
? "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)]",
|
: "text-[var(--color-text-muted)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</span>
|
<span>{parseGroupDate(date)?.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) || "—"}</span>
|
||||||
|
|
@ -280,7 +285,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -289,31 +294,35 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
{/* City pills */}
|
{/* City pills */}
|
||||||
{sortedCities.length > 1 && (
|
{sortedCities.length > 1 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
|
onClick={() => setFilters((current) => ({ ...current, selectedCity: "" }))}
|
||||||
className={[
|
className={[
|
||||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
"rounded-full px-3 py-1.5 text-xs font-medium",
|
||||||
!filters.selectedCity
|
!filters.selectedCity
|
||||||
? "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)]",
|
: "text-[var(--color-text-muted)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
Все города
|
Все города
|
||||||
</button>
|
</Button>
|
||||||
{sortedCities.map((city) => {
|
{sortedCities.map((city) => {
|
||||||
const count = cityDeliveryMap.get(city) || 0;
|
const count = cityDeliveryMap.get(city) || 0;
|
||||||
const selected = filters.selectedCity === city;
|
const selected = filters.selectedCity === city;
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={city}
|
key={city}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
|
onClick={() => setFilters((current) => ({ ...current, selectedCity: city }))}
|
||||||
className={[
|
className={[
|
||||||
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
||||||
selected
|
selected
|
||||||
? "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)]",
|
: "text-[var(--color-text-muted)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<span>{city}</span>
|
<span>{city}</span>
|
||||||
|
|
@ -322,7 +331,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -347,7 +356,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
statusBuckets.get(s).items.push(item);
|
statusBuckets.get(s).items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOrder = ["driver_assigned", "loaded", "on_route", "delivered", "picked_up", "problem"];
|
const statusOrder = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up", "paid_storage", "problem"];
|
||||||
const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => {
|
const sortedBuckets = Array.from(statusBuckets.entries()).sort(([a], [b]) => {
|
||||||
const ia = statusOrder.indexOf(a);
|
const ia = statusOrder.indexOf(a);
|
||||||
const ib = statusOrder.indexOf(b);
|
const ib = statusOrder.indexOf(b);
|
||||||
|
|
@ -359,8 +368,9 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel key={group.date} className="space-y-4 p-5">
|
<Panel key={group.date} className="space-y-4 p-5">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
className="flex w-full items-center gap-3 text-left"
|
className="flex w-full items-center gap-3 text-left"
|
||||||
onClick={() => toggleDate(group.date)}
|
onClick={() => toggleDate(group.date)}
|
||||||
>
|
>
|
||||||
|
|
@ -381,7 +391,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
<Badge key={status} tone={tone}>{count} {label}</Badge>
|
<Badge key={status} tone={tone}>{count} {label}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="space-y-4 pt-1">
|
<div className="space-y-4 pt-1">
|
||||||
|
|
@ -393,10 +403,10 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<Button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
className="rounded-[24px] p-4 text-left"
|
||||||
onClick={() => onOpenOrder?.(item.id)}
|
onClick={() => onOpenOrder?.(item.id)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
|
@ -414,7 +424,7 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUs
|
||||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||||
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
|
{item.deliveryAddress || item.delivery_address || "Адрес не указан"}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,11 @@ const parseOrderItems = (order) => {
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
|
export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment }) => {
|
||||||
const [stopWords, setStopWords] = React.useState([]);
|
const [stopWords, setStopWords] = React.useState([]);
|
||||||
const [scopeActive, setScopeActive] = React.useState(true);
|
const [scopeActive, setScopeActive] = React.useState(true);
|
||||||
|
const [savedShipment, setSavedShipment] = React.useState([]);
|
||||||
|
const [justSaved, setJustSaved] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!supabase) return;
|
if (!supabase) return;
|
||||||
|
|
@ -109,10 +111,46 @@ export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
|
||||||
if (!stopWords.length || !scopeActive) return allItems;
|
if (!stopWords.length || !scopeActive) return allItems;
|
||||||
return allItems.filter((item) => !matchesStopWord(item.name, stopWords));
|
return allItems.filter((item) => !matchesStopWord(item.name, stopWords));
|
||||||
}, [allItems, stopWords, scopeActive]);
|
}, [allItems, stopWords, scopeActive]);
|
||||||
const [shippedItems, setShippedItems] = React.useState(new Set());
|
|
||||||
const [comments, setComments] = React.useState({});
|
// Restore previously saved shipment data from order
|
||||||
|
const initialShippedIds = React.useMemo(() => {
|
||||||
|
const saved = order?.driverShipmentData;
|
||||||
|
if (!Array.isArray(saved) || saved.length === 0) return new Set();
|
||||||
|
const ids = new Set();
|
||||||
|
for (const item of saved) {
|
||||||
|
if (item.shipped && item.id) ids.add(item.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [order?.driverShipmentData]);
|
||||||
|
|
||||||
|
const initialComments = React.useMemo(() => {
|
||||||
|
const saved = order?.driverShipmentData;
|
||||||
|
if (!Array.isArray(saved) || saved.length === 0) return {};
|
||||||
|
const map = {};
|
||||||
|
for (const item of saved) {
|
||||||
|
if (!item.shipped && item.id && item.comment) map[item.id] = item.comment;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [order?.driverShipmentData]);
|
||||||
|
|
||||||
|
const [shippedItems, setShippedItems] = React.useState(initialShippedIds);
|
||||||
|
const [comments, setComments] = React.useState(initialComments);
|
||||||
const [commentInput, setCommentInput] = React.useState("");
|
const [commentInput, setCommentInput] = React.useState("");
|
||||||
|
|
||||||
|
// Sync state when order data changes (e.g. after save)
|
||||||
|
React.useEffect(() => {
|
||||||
|
setShippedItems(initialShippedIds);
|
||||||
|
setComments(initialComments);
|
||||||
|
}, [initialShippedIds, initialComments]);
|
||||||
|
|
||||||
|
// Track last saved shipment data for visual display
|
||||||
|
React.useEffect(() => {
|
||||||
|
const saved = order?.driverShipmentData;
|
||||||
|
if (Array.isArray(saved) && saved.length > 0) {
|
||||||
|
setSavedShipment(saved);
|
||||||
|
}
|
||||||
|
}, [order?.driverShipmentData]);
|
||||||
|
|
||||||
const toggleItem = (itemId) => {
|
const toggleItem = (itemId) => {
|
||||||
setShippedItems((prev) => {
|
setShippedItems((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -261,6 +299,94 @@ export const DriverShipmentPanel = ({ order, onShipmentChange }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onSaveShipment && items.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const data = 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] || ""),
|
||||||
|
}));
|
||||||
|
setSavedShipment(data);
|
||||||
|
setJustSaved(true);
|
||||||
|
onSaveShipment(data);
|
||||||
|
}}
|
||||||
|
disabled={isSavingShipment || shippedCount === 0}
|
||||||
|
>
|
||||||
|
{isSavingShipment ? "Сохраняем..." : justSaved ? "Сохранить изменения" : "Сохранить"}
|
||||||
|
</Button>
|
||||||
|
{shippedCount > 0 && shippedCount < items.length && (
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Частичная отгрузка: {shippedCount}/{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Saved shipment summary — show prominently after save */}
|
||||||
|
{justSaved && savedShipment.length > 0 && (
|
||||||
|
<div className="rounded-xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">✅</span>
|
||||||
|
<strong className="text-[var(--color-accent)]">
|
||||||
|
Отгрузка сохранена: {savedShipment.filter((i) => i.shipped).length} из {savedShipment.length}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setJustSaved(false)}>
|
||||||
|
Изменить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{savedShipment.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={[
|
||||||
|
"rounded-lg px-3 py-2",
|
||||||
|
item.shipped
|
||||||
|
? "bg-[var(--color-surface-strong)] border border-[var(--color-accent)]"
|
||||||
|
: "bg-[var(--color-warning-soft)] border border-[var(--color-warning)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={item.shipped ? "text-[var(--color-accent)]" : "text-[var(--color-warning)]"}>
|
||||||
|
{item.shipped ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
<span className={item.shipped ? "text-[var(--color-text-muted)] line-through" : "text-[var(--color-text)] font-medium"}>
|
||||||
|
{item.name}{item.quantity ? ` — ${item.quantity}${item.unit ? ` ${item.unit}` : ""}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!item.shipped && item.comment && (
|
||||||
|
<div className="mt-1 ml-6 text-xs text-[var(--color-warning)]">
|
||||||
|
Причина: {item.comment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Restore saved state on load (when not justSaved) */}
|
||||||
|
{!justSaved && savedShipment.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-[var(--color-accent)] bg-[var(--color-accent-soft)] px-3 py-2 text-xs text-[var(--color-accent)]">
|
||||||
|
✅ Отгрузка ранее сохранена: {savedShipment.filter((i) => i.shipped).length}/{savedShipment.length} позиций
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="underline text-[var(--color-text-muted)]"
|
||||||
|
onClick={() => setJustSaved(true)}
|
||||||
|
>
|
||||||
|
Показать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,18 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
filterOrderGroups,
|
filterOrderGroups,
|
||||||
getOrderGroupDisplayStatusLabel,
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
|
@ -10,11 +24,237 @@ import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SkeletonPage } from "../UI/Loading";
|
import { SkeletonPage } from "../UI/Loading";
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
const fmtDate = (d) => {
|
||||||
|
if (!d) return "";
|
||||||
|
const [y, m, day] = d.split("-");
|
||||||
|
if (!y || !m || !day) return d;
|
||||||
|
return `${day}.${m}.${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default priority: agreed first, manual_required second, then funnel
|
||||||
|
const DEFAULT_FUNNEL_ORDER = [
|
||||||
|
"delivery:agreed",
|
||||||
|
"status:manual_required",
|
||||||
|
"status:ready_for_notification",
|
||||||
|
"delivery:pending_confirmation",
|
||||||
|
"status:first_sms_sent",
|
||||||
|
"status:second_sms_sent",
|
||||||
|
"delivery:driver_assigned",
|
||||||
|
"delivery:loaded",
|
||||||
|
"delivery:on_route",
|
||||||
|
"delivery:delivered",
|
||||||
|
"delivery:picked_up",
|
||||||
|
"delivery:paid_storage",
|
||||||
|
"delivery:problem",
|
||||||
|
"delivery:cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "logistics-section-order";
|
||||||
|
const COLLAPSED_KEY = "logistics-section-collapsed";
|
||||||
|
|
||||||
|
// Load custom order from localStorage, merge with defaults
|
||||||
|
const loadCustomOrder = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCustomOrder = (order) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load collapsed sections from localStorage
|
||||||
|
const loadCollapsedSections = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCollapsedSections = (collapsedSet) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet]));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
|
||||||
|
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
|
||||||
|
const MIN_W = "min-w-[1080px]";
|
||||||
|
|
||||||
|
const TableHeader = () => (
|
||||||
|
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]`}>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Клиент</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
|
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stale = updatedAt > 24h and not in agreed/delivered/picked_up/cancelled
|
||||||
|
const STALE_STATUSES = ["delivery:agreed", "delivery:driver_assigned", "delivery:loaded", "delivery:on_route", "delivery:delivered", "delivery:picked_up", "delivery:pickup", "delivery:cancelled"];
|
||||||
|
const isStale = (group) => {
|
||||||
|
const sv = getOrderGroupDisplayStatusValue(group);
|
||||||
|
if (STALE_STATUSES.includes(sv)) return false;
|
||||||
|
if (!group.updatedAt) return false;
|
||||||
|
const diff = Date.now() - new Date(group.updatedAt).getTime();
|
||||||
|
return diff > 24 * 60 * 60 * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0));
|
||||||
|
|
||||||
|
const renderRow = (group, onSelectSet) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${isStale(group) ? "bg-[rgba(191,123,33,0.06)]" : ""}`}
|
||||||
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
|
<div className="text-xs font-medium leading-snug break-words" style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
||||||
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)] flex items-center gap-1">
|
||||||
|
{group.customerPhone || ""}
|
||||||
|
{isLinkOpened(group) && <span title="Клиент открывал ссылку" style={{ color: "#22c55e", fontSize: "11px" }}>👁</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{group.city || group.customerAddress || "—"}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.deliveryDate ? (
|
||||||
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(group.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sortable section wrapper
|
||||||
|
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: statusValue });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Section header — drag handle + collapse toggle */}
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
|
||||||
|
title="Перетащите для изменения порядка"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
||||||
|
<circle cx="9" cy="5" r="1.8" />
|
||||||
|
<circle cx="15" cy="5" r="1.8" />
|
||||||
|
<circle cx="9" cy="12" r="1.8" />
|
||||||
|
<circle cx="15" cy="12" r="1.8" />
|
||||||
|
<circle cx="9" cy="19" r="1.8" />
|
||||||
|
<circle cx="15" cy="19" r="1.8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapse toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm 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}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className={MIN_W}>
|
||||||
|
<TableHeader />
|
||||||
|
{groups.map((g) => renderRow(g, onSelectSet))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
|
||||||
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
|
||||||
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
|
const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections());
|
||||||
|
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
||||||
|
const custom = loadCustomOrder();
|
||||||
|
return custom || [...DEFAULT_FUNNEL_ORDER];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
const cities = React.useMemo(() => {
|
const cities = React.useMemo(() => {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
|
|
@ -42,47 +282,64 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
return map;
|
return map;
|
||||||
}, [filteredGroups]);
|
}, [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 = () => (
|
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
|
||||||
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
const sortedEntries = React.useMemo(() => {
|
||||||
<tr>
|
const present = new Set(statusGroups.keys());
|
||||||
<th className="px-4 py-3 font-medium">Клиент</th>
|
const result = [];
|
||||||
<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>
|
// First: statuses in custom order that are present
|
||||||
<th className="px-4 py-3 font-medium hidden lg:table-cell">Водитель</th>
|
for (const statusValue of sectionOrder) {
|
||||||
<th className="px-4 py-3 font-medium">Статус</th>
|
if (present.has(statusValue)) {
|
||||||
<th className="px-4 py-3 font-medium hidden md:table-cell">Обновлён</th>
|
const data = statusGroups.get(statusValue);
|
||||||
</tr>
|
result.push([statusValue, data]);
|
||||||
</thead>
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Then: any statuses not in sectionOrder (new statuses), sorted alphabetically
|
||||||
|
for (const [statusValue, data] of statusGroups.entries()) {
|
||||||
|
if (!sectionOrder.includes(statusValue)) {
|
||||||
|
result.push([statusValue, data]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [statusGroups, sectionOrder]);
|
||||||
|
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
setSectionOrder((prevOrder) => {
|
||||||
|
// Build the full order including any new statuses
|
||||||
|
const allIds = sortedEntries.map(([id]) => id);
|
||||||
|
const oldIndex = allIds.indexOf(active.id);
|
||||||
|
const newIndex = allIds.indexOf(over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return prevOrder;
|
||||||
|
|
||||||
|
const newAllOrder = arrayMove(allIds, oldIndex, newIndex);
|
||||||
|
|
||||||
|
// Merge: replace positions of known statuses, keep unknown at end
|
||||||
|
// Save the full new order so it persists
|
||||||
|
saveCustomOrder(newAllOrder);
|
||||||
|
return newAllOrder;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SkeletonPage panels={3} />;
|
return <SkeletonPage panels={3} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 fs-zone-table">
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||||
|
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{totalGroups} групп</Badge>
|
<Badge tone="neutral">{totalGroups} групп</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -100,23 +357,27 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
По этому поиску ничего не найдено.
|
По этому поиску ничего не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<DndContext
|
||||||
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
sensors={sensors}
|
||||||
const idxA = FUNNEL_ORDER.indexOf(a);
|
collisionDetection={closestCenter}
|
||||||
const idxB = FUNNEL_ORDER.indexOf(b);
|
onDragEnd={handleDragEnd}
|
||||||
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
>
|
||||||
if (idxA === -1) return 1;
|
<SortableContext
|
||||||
if (idxB === -1) return -1;
|
items={sortedEntries.map(([id]) => id)}
|
||||||
return idxA - idxB;
|
strategy={verticalListSortingStrategy}
|
||||||
}).map(([statusValue, { label, groups }]) => {
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sortedEntries.map(([statusValue, { label, groups }]) => {
|
||||||
const isCollapsed = collapsedSections.has(statusValue);
|
const isCollapsed = collapsedSections.has(statusValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel key={statusValue} className="overflow-hidden p-0">
|
<SortableSection
|
||||||
<button
|
key={statusValue}
|
||||||
type="button"
|
statusValue={statusValue}
|
||||||
className="flex w-full items-center justify-between px-5 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
|
label={label}
|
||||||
onClick={() => {
|
groups={groups}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggle={() => {
|
||||||
setCollapsedSections((prev) => {
|
setCollapsedSections((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(statusValue)) {
|
if (next.has(statusValue)) {
|
||||||
|
|
@ -124,70 +385,17 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
|
||||||
} else {
|
} else {
|
||||||
next.add(statusValue);
|
next.add(statusValue);
|
||||||
}
|
}
|
||||||
|
saveCollapsedSections(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
onSelectSet={onSelectSet}
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full border-collapse border-t border-[var(--color-border)]">
|
|
||||||
<TableHeader />
|
|
||||||
<tbody>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<tr
|
|
||||||
key={group.id}
|
|
||||||
className="cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]"
|
|
||||||
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
|
||||||
<div className="text-xs text-[var(--color-text-muted)] sm:hidden">{group.customerPhone || "—"}</div>
|
|
||||||
<div className="text-xs text-[var(--color-text-muted)] md:hidden">{group.deliveryDate || "—"}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-sm hidden sm:table-cell">
|
|
||||||
{group.city || group.customerAddress || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-sm hidden md:table-cell">
|
|
||||||
{group.deliveryDate
|
|
||||||
? <span>{group.deliveryDate}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
|
||||||
: <span className="text-[var(--color-text-muted)]">—</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-sm hidden lg:table-cell">
|
|
||||||
{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>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Bell, Check, CheckCheck, Settings, X } from "../UI/Icons";
|
import { Bell, Check, CheckCheck, Settings, X } from "../UI/Icons";
|
||||||
|
|
||||||
const TYPE_ICONS = {
|
const TYPE_ICONS = {
|
||||||
|
|
@ -48,7 +47,8 @@ export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onM
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={bellRef}>
|
<div className="relative" ref={bellRef}>
|
||||||
<button
|
<button
|
||||||
className="relative flex h-9 w-9 items-center justify-center rounded-full transition hover:bg-[var(--color-surface-strong)]"
|
type="button"
|
||||||
|
className="relative flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] bg-transparent text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount})` : ""}`}
|
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount})` : ""}`}
|
||||||
>
|
>
|
||||||
|
|
@ -68,7 +68,8 @@ export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onM
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<button
|
<button
|
||||||
className="rounded p-1 text-xs text-[var(--color-accent)] hover:bg-[var(--color-surface-strong)]"
|
type="button"
|
||||||
|
className="rounded border border-transparent p-1 text-xs text-[var(--color-accent)] hover:bg-[var(--color-surface-strong)]"
|
||||||
onClick={onMarkAllAsRead}
|
onClick={onMarkAllAsRead}
|
||||||
title="Прочитать все"
|
title="Прочитать все"
|
||||||
>
|
>
|
||||||
|
|
@ -77,7 +78,8 @@ export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onM
|
||||||
)}
|
)}
|
||||||
{onOpenSettings && (
|
{onOpenSettings && (
|
||||||
<button
|
<button
|
||||||
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
type="button"
|
||||||
|
className="rounded border border-transparent p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
onClick={() => { onOpenSettings(); setIsOpen(false); }}
|
onClick={() => { onOpenSettings(); setIsOpen(false); }}
|
||||||
title="Настройки"
|
title="Настройки"
|
||||||
>
|
>
|
||||||
|
|
@ -85,7 +87,8 @@ export function NotificationBell({ notifications, unreadCount, onMarkAsRead, onM
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="rounded p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
type="button"
|
||||||
|
className="rounded border border-transparent p-1 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { useNotificationPreferences } from "../../hooks/useNotifications";
|
import { useNotificationPreferences } from "../../hooks/useNotifications";
|
||||||
import { usePushNotifications } from "../../hooks/usePushNotifications";
|
import { usePushNotifications } from "../../hooks/usePushNotifications";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
import { Bell, Settings } from "../UI/Icons";
|
import { Bell, Settings } from "../UI/Icons";
|
||||||
import { Skeleton } from "../UI/Loading";
|
import { Skeleton } from "../UI/Loading";
|
||||||
|
|
||||||
|
|
@ -27,12 +28,12 @@ export function NotificationSettings({ userId, userRole, onBack }) {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button
|
<Button
|
||||||
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
>
|
>
|
||||||
← Назад
|
← Назад
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
|
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,12 +68,12 @@ export function NotificationSettings({ userId, userRole, onBack }) {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button
|
<Button
|
||||||
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
>
|
>
|
||||||
← Назад
|
← Назад
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
|
<h2 className="text-lg font-semibold">Настройки уведомлений</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +95,7 @@ export function NotificationSettings({ userId, userRole, onBack }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSupported && (
|
{isSupported && (
|
||||||
<button
|
<Button
|
||||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
isSubscribed ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
isSubscribed ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -106,7 +107,7 @@ export function NotificationSettings({ userId, userRole, onBack }) {
|
||||||
isSubscribed ? "translate-x-5" : "translate-x-0"
|
isSubscribed ? "translate-x-5" : "translate-x-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
@ -124,7 +125,7 @@ export function NotificationSettings({ userId, userRole, onBack }) {
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${
|
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)]"
|
prefs[key] ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -136,7 +137,7 @@ export function NotificationSettings({ userId, userRole, onBack }) {
|
||||||
prefs[key] ? "translate-x-5" : "translate-x-0"
|
prefs[key] ? "translate-x-5" : "translate-x-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
|
||||||
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
|
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
|
||||||
|
|
||||||
|
|
@ -58,8 +59,8 @@ const CalendarWidget = ({
|
||||||
return (
|
return (
|
||||||
<div className={layoutClassName}>
|
<div className={layoutClassName}>
|
||||||
<div className={calendarClassName}>
|
<div className={calendarClassName}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
aria-label={label}
|
aria-label={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"
|
||||||
|
|
@ -67,7 +68,7 @@ const CalendarWidget = ({
|
||||||
>
|
>
|
||||||
<span>{selectedDate ? formatDateForDisplay(selectedDate) : "Выберите дату"}</span>
|
<span>{selectedDate ? formatDateForDisplay(selectedDate) : "Выберите дату"}</span>
|
||||||
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
||||||
</button>
|
</Button>
|
||||||
{isCalendarOpen ? (
|
{isCalendarOpen ? (
|
||||||
<div className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3 shadow-lg absolute left-0 top-full z-50 mt-2 w-[300px]">
|
<div className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3 shadow-lg absolute left-0 top-full z-50 mt-2 w-[300px]">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|
@ -87,7 +88,7 @@ const CalendarWidget = ({
|
||||||
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) => new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
onClick={() => setCurrentMonth((month) => new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
|
|
@ -95,7 +96,7 @@ const CalendarWidget = ({
|
||||||
<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) => new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
onClick={() => setCurrentMonth((month) => new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
|
|
@ -123,9 +124,9 @@ const CalendarWidget = ({
|
||||||
const dayNumber = String(day.getDate()).padStart(2, "0");
|
const dayNumber = String(day.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
key={dateKey}
|
key={dateKey}
|
||||||
type="button"
|
variant="ghost"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
title={isWeekend ? "Выходной, доставки нет" : isSelectable ? "Можно выбрать" : "Недоступно"}
|
title={isWeekend ? "Выходной, доставки нет" : isSelectable ? "Можно выбрать" : "Недоступно"}
|
||||||
className={[
|
className={[
|
||||||
|
|
@ -150,7 +151,7 @@ const CalendarWidget = ({
|
||||||
className="absolute inset-x-2 top-1/2 h-px -rotate-12 bg-[var(--color-text-muted)] opacity-70"
|
className="absolute inset-x-2 top-1/2 h-px -rotate-12 bg-[var(--color-text-muted)] opacity-70"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,9 +163,9 @@ const CalendarWidget = ({
|
||||||
</div>
|
</div>
|
||||||
<div className={timeClassName}>
|
<div className={timeClassName}>
|
||||||
{timeOptions.map((option) => (
|
{timeOptions.map((option) => (
|
||||||
<button
|
<Button
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
variant="ghost"
|
||||||
aria-pressed={selectedTime === option}
|
aria-pressed={selectedTime === option}
|
||||||
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",
|
||||||
|
|
@ -175,7 +176,7 @@ const CalendarWidget = ({
|
||||||
onClick={() => onTimeChange(option)}
|
onClick={() => onTimeChange(option)}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,83 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
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";
|
||||||
|
|
||||||
|
const DriverDropdown = ({ value, drivers, onChange, disabled, placeholder }) => {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const menuRef = React.useRef(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const handlePointerDown = (event) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) setIsOpen(false);
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === "Escape") setIsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const selectedDriver = drivers.find((d) => d.id === value);
|
||||||
|
const selectedLabel = selectedDriver ? (selectedDriver.name || selectedDriver.email) : placeholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className={["flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition disabled:opacity-50",
|
||||||
|
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(" ")}
|
||||||
|
onClick={() => !disabled && setIsOpen((c) => !c)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{selectedLabel}</span>
|
||||||
|
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="absolute left-0 right-0 top-full z-20 mt-2 max-h-[280px] overflow-y-auto rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
|
||||||
|
>
|
||||||
|
{drivers.map((driver) => {
|
||||||
|
const isSelected = driver.id === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={driver.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={["flex w-full items-center justify-between px-4 py-2.5 text-left text-sm transition",
|
||||||
|
isSelected
|
||||||
|
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(driver.id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{driver.name || driver.email}</span>
|
||||||
|
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const DriverAssignmentPanel = ({
|
const DriverAssignmentPanel = ({
|
||||||
order,
|
order,
|
||||||
userRole,
|
userRole,
|
||||||
|
|
@ -57,19 +131,13 @@ const DriverAssignmentPanel = ({
|
||||||
) : null}
|
) : null}
|
||||||
{!isDriverLocked ? (
|
{!isDriverLocked ? (
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
|
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
|
||||||
<Select
|
<DriverDropdown
|
||||||
className="h-[46px] py-0"
|
|
||||||
value={selectedDriverId}
|
value={selectedDriverId}
|
||||||
onChange={(e) => {
|
drivers={drivers}
|
||||||
onDriverSelect(e.target.value);
|
onChange={onDriverSelect}
|
||||||
}}
|
|
||||||
disabled={isSavingDriverAssignment}
|
disabled={isSavingDriverAssignment}
|
||||||
>
|
placeholder={order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}
|
||||||
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
|
/>
|
||||||
{drivers.map((driver) => (
|
|
||||||
<option key={driver.id} value={driver.id}>{driver.name || driver.email}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Button
|
<Button
|
||||||
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
|
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
|
||||||
onClick={onConfirmDriver}
|
onClick={onConfirmDriver}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@ import {
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
||||||
|
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch { return "—"; }
|
||||||
|
};
|
||||||
|
|
||||||
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||||
|
|
||||||
|
|
@ -67,8 +75,8 @@ const ConfirmModal = ({ open, title, message, onConfirm, onCancel }) => {
|
||||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||||
{message && <p className="mt-2 text-sm text-[var(--color-text-muted)]">{message}</p>}
|
{message && <p className="mt-2 text-sm text-[var(--color-text-muted)]">{message}</p>}
|
||||||
<div className="mt-5 flex justify-end gap-3">
|
<div className="mt-5 flex justify-end gap-3">
|
||||||
<button type="button" className="rounded-xl border border-[var(--color-border)] px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface)]" onClick={onCancel}>Отмена</button>
|
<Button variant="secondary" onClick={onCancel}>Отмена</Button>
|
||||||
<button type="button" className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-white hover:opacity-90" onClick={onConfirm}>Подтвердить</button>
|
<Button variant="primary" onClick={onConfirm}>Подтвердить</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -342,8 +350,8 @@ const CollapsibleOrderComposition = ({ order }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
className="flex w-full items-center justify-between text-left"
|
className="flex w-full items-center justify-between text-left"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
|
|
@ -365,7 +373,7 @@ const CollapsibleOrderComposition = ({ order }) => {
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{!orders.length ? (
|
{!orders.length ? (
|
||||||
|
|
@ -544,7 +552,8 @@ export const OrderDetailPanel = ({
|
||||||
drivers = [],
|
drivers = [],
|
||||||
onAssignDriver,
|
onAssignDriver,
|
||||||
onChangeDeliveryStatus,
|
onChangeDeliveryStatus,
|
||||||
userRole,
|
onSaveShipmentData,
|
||||||
|
userRole = "driver",
|
||||||
}) => {
|
}) => {
|
||||||
const [problemReason, setProblemReason] = React.useState(null);
|
const [problemReason, setProblemReason] = React.useState(null);
|
||||||
const [pendingStatus, setPendingStatus] = React.useState(null);
|
const [pendingStatus, setPendingStatus] = React.useState(null);
|
||||||
|
|
@ -552,6 +561,7 @@ export const OrderDetailPanel = ({
|
||||||
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 [shipmentState, setShipmentState] = React.useState(null);
|
||||||
|
const [isSavingShipment, setIsSavingShipment] = React.useState(false);
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||||
const [driverMessage, setDriverMessage] = React.useState("");
|
const [driverMessage, setDriverMessage] = React.useState("");
|
||||||
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
|
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
|
||||||
|
|
@ -564,6 +574,26 @@ export const OrderDetailPanel = ({
|
||||||
const handleShipmentChange = React.useCallback((state) => {
|
const handleShipmentChange = React.useCallback((state) => {
|
||||||
setShipmentState(state);
|
setShipmentState(state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveShipment = React.useCallback(async (shipmentData) => {
|
||||||
|
if (!onSaveShipmentData) return;
|
||||||
|
setIsSavingShipment(true);
|
||||||
|
try {
|
||||||
|
const result = await onSaveShipmentData({
|
||||||
|
orderGroupId: order.id,
|
||||||
|
shipmentData,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
setFormMessage(result.error || "Не удалось сохранить данные отгрузки");
|
||||||
|
} else {
|
||||||
|
setFormMessage("Данные отгрузки сохранены");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setFormMessage("Не удалось сохранить данные отгрузки");
|
||||||
|
} finally {
|
||||||
|
setIsSavingShipment(false);
|
||||||
|
}
|
||||||
|
}, [onSaveShipmentData, order?.id]);
|
||||||
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);
|
||||||
|
|
@ -613,7 +643,7 @@ export const OrderDetailPanel = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDeliveryAgreed = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up"].includes(order.deliveryStatus || order.delivery_status);
|
const isDeliveryAgreed = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up", "pickup"].includes(order.deliveryStatus || order.delivery_status);
|
||||||
const isPickupOrder = order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup";
|
const isPickupOrder = order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup";
|
||||||
// Show "agreed" banner only when selected tab matches the already-agreed type
|
// Show "agreed" banner only when selected tab matches the already-agreed type
|
||||||
const agreedTypeMatchesTab = isDeliveryAgreed && !isEditingDate && (
|
const agreedTypeMatchesTab = isDeliveryAgreed && !isEditingDate && (
|
||||||
|
|
@ -621,10 +651,32 @@ export const OrderDetailPanel = ({
|
||||||
|| (deliveryType === "delivery" && !isPickupOrder)
|
|| (deliveryType === "delivery" && !isPickupOrder)
|
||||||
);
|
);
|
||||||
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
||||||
const agreedDeliveryLabel = [
|
const agreedDeliveryLabel = (() => {
|
||||||
|
if (isPickupOrder) {
|
||||||
|
const parts = [
|
||||||
|
formatDeliveryDateDisplay(order.pickupDate || order.pickup_date),
|
||||||
|
order.pickupTimeSlot || order.pickup_time_slot || order.deliveryTime || order.deliveryHalfDay,
|
||||||
|
].filter((value) => value && value !== "Нет данных");
|
||||||
|
if (parts.length > 0) return parts.join(" · ");
|
||||||
|
// Fallback: show manual_confirmation_at date if no pickup date
|
||||||
|
if (order.manualConfirmationAt || order.manual_confirmation_at) {
|
||||||
|
const mcDate = order.manualConfirmationAt || order.manual_confirmation_at;
|
||||||
|
return `Согласовано: ${fmtTime(mcDate)}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
formatDeliveryDateDisplay(order.deliveryDate),
|
formatDeliveryDateDisplay(order.deliveryDate),
|
||||||
order.deliveryTime || order.deliveryHalfDay,
|
order.deliveryTime || order.deliveryHalfDay,
|
||||||
].filter((value) => value && value !== "Нет данных").join(" · ");
|
].filter((value) => value && value !== "Нет данных");
|
||||||
|
if (parts.length > 0) return parts.join(" · ");
|
||||||
|
// Fallback: show manual_confirmation_at date if no delivery date
|
||||||
|
if (order.manualConfirmationAt || order.manual_confirmation_at) {
|
||||||
|
const mcDate = order.manualConfirmationAt || order.manual_confirmation_at;
|
||||||
|
return `Согласовано: ${fmtTime(mcDate)}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})();
|
||||||
|
|
||||||
const handleSaveDeliveryChoice = async () => {
|
const handleSaveDeliveryChoice = async () => {
|
||||||
const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
|
const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
|
||||||
|
|
@ -710,17 +762,18 @@ export const OrderDetailPanel = ({
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const isPickup = isPickupOrder;
|
const isPickup = isPickupOrder;
|
||||||
|
const effectiveAddress = isPickup
|
||||||
|
? (order.customerAddress || "")
|
||||||
|
: (order.deliveryAddress || "");
|
||||||
|
const requiresAddress = (order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && !effectiveAddress;
|
||||||
const deliveryTypeLabel = isPickup
|
const deliveryTypeLabel = isPickup
|
||||||
? "Самовывоз"
|
? "Самовывоз"
|
||||||
: (order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address")
|
: requiresAddress
|
||||||
? "Доставка (требуется адрес)"
|
? "Доставка (требуется адрес)"
|
||||||
: "Доставка";
|
: "Доставка";
|
||||||
const dateLabel = isPickup ? "Дата самовывоза" : "Дата доставки";
|
const dateLabel = isPickup ? "Дата самовывоза" : "Дата доставки";
|
||||||
const timeLabel = isPickup ? "Время самовывоза" : "Время доставки";
|
const timeLabel = isPickup ? "Время самовывоза" : "Время доставки";
|
||||||
const addressLabel = isPickup ? "Адрес клиента" : "Адрес доставки";
|
const addressLabel = isPickup ? "Адрес клиента" : "Адрес доставки";
|
||||||
const effectiveAddress = isPickup
|
|
||||||
? (order.customerAddress || "")
|
|
||||||
: (order.deliveryAddress || "");
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-4">
|
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -740,7 +793,7 @@ export const OrderDetailPanel = ({
|
||||||
Тип
|
Тип
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{deliveryTypeLabel}</p>
|
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{deliveryTypeLabel}</p>
|
||||||
{(order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && (
|
{requiresAddress && (
|
||||||
<div className="mt-2 flex items-start gap-2 rounded-xl border border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] p-3">
|
<div className="mt-2 flex items-start gap-2 rounded-xl border border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] p-3">
|
||||||
<span className="text-lg">📍</span>
|
<span className="text-lg">📍</span>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -840,6 +893,12 @@ export const OrderDetailPanel = ({
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{isPickupOrder ? "Статус самовывоза" : "Статус доставки"}</p>
|
<p className="text-xs text-[var(--color-text-muted)]">{isPickupOrder ? "Статус самовывоза" : "Статус доставки"}</p>
|
||||||
<p className="font-medium !text-[var(--color-text)]">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p>
|
<p className="font-medium !text-[var(--color-text)]">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{(order.pickupCode || order.pickup_code) && (order.deliveryType === "pickup" || order.delivery_type === "pickup") ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Код выдачи</p>
|
||||||
|
<p className="text-2xl font-bold tracking-[0.3em] text-[var(--color-accent)]">{order.pickupCode || order.pickup_code}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|
@ -857,22 +916,22 @@ export const OrderDetailPanel = ({
|
||||||
<div className="flex gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
|
<div className="flex gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition ${
|
className={["flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition",
|
||||||
deliveryType === "delivery"
|
deliveryType === "delivery"
|
||||||
? "bg-[var(--color-accent)] text-white"
|
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
|
||||||
}`}
|
].join(" ")}
|
||||||
onClick={() => { setDeliveryType("delivery"); setFormMessage(""); }}
|
onClick={() => { setDeliveryType("delivery"); setFormMessage(""); }}
|
||||||
>
|
>
|
||||||
🚚 Доставка
|
🚚 Доставка
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition ${
|
className={["flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition",
|
||||||
deliveryType === "pickup"
|
deliveryType === "pickup"
|
||||||
? "bg-[var(--color-accent)] text-white"
|
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
|
||||||
}`}
|
].join(" ")}
|
||||||
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
|
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
|
||||||
>
|
>
|
||||||
🏪 Самовывоз
|
🏪 Самовывоз
|
||||||
|
|
@ -908,7 +967,7 @@ export const OrderDetailPanel = ({
|
||||||
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold">
|
<p className="mt-1 text-lg font-semibold">
|
||||||
{agreedDeliveryLabel || "Дата и время сохранены"}
|
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="accent">Согласовано</Badge>
|
<Badge tone="accent">Согласовано</Badge>
|
||||||
|
|
@ -1004,7 +1063,13 @@ export const OrderDetailPanel = ({
|
||||||
if (action.type === "hint") {
|
if (action.type === "hint") {
|
||||||
setFormMessage(action.hint);
|
setFormMessage(action.hint);
|
||||||
} else if (action.type === "status") {
|
} else if (action.type === "status") {
|
||||||
setConfirmAction({ type: "status", status: action.status });
|
setConfirmAction({
|
||||||
|
type: "status",
|
||||||
|
status: action.status,
|
||||||
|
label: action.label,
|
||||||
|
mismatch: action.mismatch,
|
||||||
|
deliveryType: action.deliveryType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1023,7 +1088,7 @@ export const OrderDetailPanel = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{userRole === "driver" && order ? (
|
{userRole === "driver" && order ? (
|
||||||
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} />
|
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} onSaveShipment={handleSaveShipment} isSavingShipment={isSavingShipment} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{userRole === "driver" && order && onChangeDeliveryStatus ? (
|
{userRole === "driver" && order && onChangeDeliveryStatus ? (
|
||||||
|
|
@ -1048,44 +1113,52 @@ export const OrderDetailPanel = ({
|
||||||
const currentStatus = order.deliveryStatus || order.delivery_status;
|
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||||
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
|
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
|
||||||
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
|
||||||
|
const isPickup = (order.deliveryType || order.delivery_type) === "pickup" || currentStatus === "pickup";
|
||||||
|
|
||||||
let statusOptions = [];
|
let statusOptions = [];
|
||||||
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
||||||
statusOptions = [];
|
statusOptions = [];
|
||||||
} else {
|
} else {
|
||||||
|
// Primary button matches delivery type, secondary requires confirmation
|
||||||
|
if (isPickup) {
|
||||||
statusOptions = [
|
statusOptions = [
|
||||||
{ value: "delivered", label: "Доставлено" },
|
{ value: "picked_up", label: "Вывезено", mismatch: false },
|
||||||
{ value: "picked_up", label: "Вывезено" },
|
{ value: "delivered", label: "Доставлено", mismatch: true },
|
||||||
{ value: "problem", label: "Проблема" },
|
{ value: "problem", label: "Проблема" },
|
||||||
];
|
];
|
||||||
|
} else {
|
||||||
|
statusOptions = [
|
||||||
|
{ value: "delivered", label: "Доставлено", mismatch: false },
|
||||||
|
{ value: "picked_up", label: "Вывезено", mismatch: true },
|
||||||
|
{ value: "problem", label: "Проблема" },
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusOptions.length === 0) return null;
|
if (statusOptions.length === 0) return null;
|
||||||
|
|
||||||
return statusOptions.map((statusOption) => {
|
return statusOptions.map((statusOption) => {
|
||||||
const isSelected = pendingStatus?.value === statusOption.value;
|
const isSelected = pendingStatus?.value === statusOption.value;
|
||||||
const isDeliveredBtn = statusOption.value === "delivered";
|
const isMismatch = statusOption.mismatch;
|
||||||
const deliveryBlocked = isDeliveredBtn && shipmentState && !shipmentState.canMarkDelivered;
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={statusOption.value}
|
key={statusOption.value}
|
||||||
variant={isSelected ? "primary" : "secondary"}
|
variant={isSelected ? "primary" : isMismatch ? "ghost" : "secondary"}
|
||||||
disabled={deliveryBlocked}
|
disabled={isSavingStatusChange}
|
||||||
title={deliveryBlocked ? "Сначала отметьте все позиции как отгруженные" : undefined}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (statusOption.value === "problem") {
|
if (statusOption.value === "problem") {
|
||||||
setProblemReason("selecting");
|
setProblemReason("selecting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingStatus({ value: statusOption.value });
|
setPendingStatus({
|
||||||
|
value: statusOption.value,
|
||||||
|
label: statusOption.label,
|
||||||
|
mismatch: isMismatch,
|
||||||
|
deliveryType: isPickup ? "pickup" : "delivery",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{statusOption.label}
|
{statusOption.label}
|
||||||
{isDeliveredBtn && shipmentState && !shipmentState.canMarkDelivered ? (
|
|
||||||
<span className="ml-1.5 text-xs text-[var(--color-text-muted)]">
|
|
||||||
({shipmentState.shipped}/{shipmentState.total})
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -1093,11 +1166,29 @@ export const OrderDetailPanel = ({
|
||||||
</div>
|
</div>
|
||||||
{pendingStatus ? (
|
{pendingStatus ? (
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
{pendingStatus.mismatch ? (
|
||||||
|
<div className="flex-1 rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
|
||||||
|
<p className="font-medium text-[var(--color-text)]">
|
||||||
|
⚠ Статус возможно не совпадает
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[var(--color-text-muted)]">
|
||||||
|
Тип отгрузки: <strong>{pendingStatus.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}</strong>.
|
||||||
|
Вы выбрали: <strong>«{pendingStatus.label}»</strong>.
|
||||||
|
{pendingStatus.deliveryType === "pickup"
|
||||||
|
? "Обычно при самовывозе используется «Вывезено»."
|
||||||
|
: "Обычно при доставке используется «Доставлено»."}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[var(--color-text)]">Подтвердить действие?</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Установить статус: <strong>{pendingStatus.label}</strong>?
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={isSavingStatusChange}
|
disabled={isSavingStatusChange}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (pendingStatus.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) return;
|
|
||||||
onChangeDeliveryStatus({
|
onChangeDeliveryStatus({
|
||||||
orderGroupId: order.id,
|
orderGroupId: order.id,
|
||||||
status: pendingStatus.value,
|
status: pendingStatus.value,
|
||||||
|
|
@ -1166,6 +1257,56 @@ export const OrderDetailPanel = ({
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<CollapsibleOrderComposition order={order} />
|
<CollapsibleOrderComposition order={order} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{userRole !== "driver" && order?.deliveryLink ? (
|
||||||
|
<Panel className="space-y-3 p-5">
|
||||||
|
<strong>Ссылка на согласование</strong>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Отправьте эту ссылку клиенту, чтобы он мог согласовать доставку или самовывоз самостоятельно.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={order.deliveryLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-2xl bg-[var(--color-accent)] px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
Открыть страницу согласования
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard?.writeText(order.deliveryLink).then(() => {
|
||||||
|
setFormMessage("Ссылка скопирована в буфер обмена");
|
||||||
|
setTimeout(() => setFormMessage(""), 3000);
|
||||||
|
}).catch(() => {
|
||||||
|
setFormMessage("Не удалось скопировать ссылку");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||||
|
</svg>
|
||||||
|
Скопировать ссылку
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{order.invitationAccessCount > 0 ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
👁 Клиент открывал страницу {order.invitationAccessCount} раз{(order.invitationLastAccessedAt || order.invitationOpenedAt) ? `, последний раз ${new Date(order.invitationLastAccessedAt || order.invitationOpenedAt).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" })}` : ""}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
⏳ Клиент ещё не открывал страницу согласования.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{userRole !== "driver" && (order?.driver_shipment_data || order?.driverShipmentData) ? (
|
{userRole !== "driver" && (order?.driver_shipment_data || order?.driverShipmentData) ? (
|
||||||
<DriverShipmentReport shipmentData={order.driver_shipment_data || order.driverShipmentData} />
|
<DriverShipmentReport shipmentData={order.driver_shipment_data || order.driverShipmentData} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1173,6 +1314,28 @@ export const OrderDetailPanel = ({
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<strong>Дополнительные данные</strong>
|
<strong>Дополнительные данные</strong>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{order.managerName ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
||||||
|
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.managerName}</p>
|
||||||
|
{order.managerTel ? (
|
||||||
|
<a href={`tel:${order.managerTel}`} className="text-sm text-[var(--color-accent)] hover:underline">{order.managerTel}</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Оплата доставки</p>
|
||||||
|
<p className="mt-1 font-medium !text-[var(--color-text)]">
|
||||||
|
{order.isPayedShip ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="rounded-full bg-[var(--color-accent-soft)] px-2 py-0.5 text-xs font-semibold text-[var(--color-accent)]">✓ Оплачено</span>
|
||||||
|
{order.payedShip ? <span className="text-sm">{Number(order.payedShip).toLocaleString("ru-RU")} ₽</span> : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">Не оплачено</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{order.firstSmsSentAt ? (
|
{order.firstSmsSentAt ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">1-е SMS отправлено</p>
|
<p className="text-xs text-[var(--color-text-muted)]">1-е SMS отправлено</p>
|
||||||
|
|
@ -1227,8 +1390,11 @@ export const OrderDetailPanel = ({
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={confirmAction?.type === 'status'}
|
open={confirmAction?.type === 'status'}
|
||||||
title="Изменить статус?"
|
title={confirmAction?.mismatch ? "⚠ Статус возможно не совпадает" : "Изменить статус?"}
|
||||||
message={`Установить статус «${STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`}
|
message={confirmAction?.mismatch
|
||||||
|
? `Тип отгрузки: ${confirmAction.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}. Вы выбрали «${confirmAction.label}». ${confirmAction.deliveryType === "pickup" ? "Обычно при самовывозе используется «Вывезено»." : "Обычно при доставке используется «Доставлено»."} Подтвердить?`
|
||||||
|
: `Установить статус «${confirmAction?.label || STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`
|
||||||
|
}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
const status = confirmAction.status;
|
const status = confirmAction.status;
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,82 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
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";
|
import { DatePicker } from "../UI/DatePicker";
|
||||||
|
|
||||||
|
const DropdownSelect = ({ value, options, onChange, placeholder }) => {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const menuRef = React.useRef(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const handlePointerDown = (event) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) setIsOpen(false);
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === "Escape") setIsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const selectedLabel = options.find((o) => o.value === value)?.label || placeholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
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(" ")}
|
||||||
|
onClick={() => setIsOpen((c) => !c)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{selectedLabel}</span>
|
||||||
|
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<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-dropdown-surface)] shadow-soft"
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = option.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={["flex w-full items-center justify-between px-4 py-2.5 text-left text-sm transition",
|
||||||
|
isSelected
|
||||||
|
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = [] }) => {
|
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;
|
||||||
|
|
@ -35,18 +109,28 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
setFilters((current) => ({ ...current, dateFrom: "", dateTo: "" }));
|
setFilters((current) => ({ ...current, dateFrom: "", dateTo: "" }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cityOptions = [
|
||||||
|
{ value: "", label: "Все города" },
|
||||||
|
...cities.map((c) => ({ value: c, label: c })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const deliveryTypeOptions = [
|
||||||
|
{ value: "", label: "Тип получения" },
|
||||||
|
{ value: "delivery", label: "Доставка" },
|
||||||
|
{ value: "pickup", label: "Самовывоз" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Row 1: Status + City + Search */}
|
{/* Row 1: Status + City + DeliveryType + Search */}
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,0.5fr)_minmax(0,1.6fr)] md:items-end">
|
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,0.5fr)_minmax(0,0.5fr)_minmax(0,1.4fr)] md:items-end">
|
||||||
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={isStatusOpen}
|
aria-expanded={isStatusOpen}
|
||||||
className={[
|
className={["flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||||
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
|
||||||
isStatusOpen
|
isStatusOpen
|
||||||
? "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)] hover:border-[var(--color-accent)]",
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||||
|
|
@ -70,8 +154,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={[
|
className={["flex w-full items-center justify-between px-4 py-2.5 text-left text-sm transition",
|
||||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition",
|
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||||
|
|
@ -82,9 +165,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
<span className="ml-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-xs font-semibold text-[var(--color-text)]">
|
<span className="ml-2 text-xs text-[var(--color-text-muted)]">{option.count || 0}</span>
|
||||||
{option.count || 0}
|
|
||||||
</span>
|
|
||||||
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -94,18 +175,21 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cities.length > 0 && (
|
{cities.length > 0 && (
|
||||||
<select
|
<DropdownSelect
|
||||||
value={filters.city || ""}
|
value={filters.city || ""}
|
||||||
onChange={(e) => updateFilter("city", e.target.value)}
|
options={cityOptions}
|
||||||
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)]"
|
onChange={(v) => updateFilter("city", v)}
|
||||||
>
|
placeholder="Все города"
|
||||||
<option value="">Все города</option>
|
/>
|
||||||
{cities.map((c) => (
|
|
||||||
<option key={c} value={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownSelect
|
||||||
|
value={filters.deliveryType || ""}
|
||||||
|
options={deliveryTypeOptions}
|
||||||
|
onChange={(v) => updateFilter("deliveryType", v)}
|
||||||
|
placeholder="Тип получения"
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="h-[46px] py-0"
|
className="h-[46px] py-0"
|
||||||
placeholder="Поиск по группе, клиенту или телефону"
|
placeholder="Поиск по группе, клиенту или телефону"
|
||||||
|
|
@ -134,7 +218,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearDateFilter}
|
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"
|
className="mb-0.5 h-[46px] 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)]"
|
||||||
>
|
>
|
||||||
✕ Сбросить
|
✕ Сбросить
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -107,15 +107,15 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{dayOrders.map((order) => (
|
{dayOrders.map((order) => (
|
||||||
<button
|
<Button
|
||||||
key={order.id}
|
key={order.id}
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
className="w-full rounded-[16px] px-3 py-3 text-left text-sm"
|
||||||
onClick={() => onOpenOrder(order.id)}
|
onClick={() => onOpenOrder(order.id)}
|
||||||
className="w-full rounded-[16px] bg-[var(--color-surface)] px-3 py-3 text-left text-sm hover:bg-[var(--color-accent-soft)]"
|
|
||||||
>
|
>
|
||||||
<div className="font-medium">{order.orderNumber}</div>
|
<div className="font-medium">{order.orderNumber}</div>
|
||||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">{order.customer.name}</div>
|
<div className="mt-1 text-xs text-[var(--color-text-muted)]">{order.customer.name}</div>
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,17 +157,17 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{dayOrders.slice(0, 2).map((order) => (
|
{dayOrders.slice(0, 2).map((order) => (
|
||||||
<button
|
<Button
|
||||||
key={order.id}
|
key={order.id}
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
className="w-full rounded-[16px] px-3 py-2 text-left text-sm"
|
||||||
onClick={() => onOpenOrder(order.id)}
|
onClick={() => onOpenOrder(order.id)}
|
||||||
className="w-full rounded-[16px] bg-[var(--color-surface)] px-3 py-2 text-left text-sm hover:bg-[var(--color-accent-soft)]"
|
|
||||||
>
|
>
|
||||||
<div className="font-medium">{order.orderNumber}</div>
|
<div className="font-medium">{order.orderNumber}</div>
|
||||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||||
{order.customer.name}
|
{order.customer.name}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
{dayOrders.length > 2 ? (
|
{dayOrders.length > 2 ? (
|
||||||
<div className="px-1 text-xs text-[var(--color-text-muted)]">
|
<div className="px-1 text-xs text-[var(--color-text-muted)]">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SkeletonTable } from "../UI/Loading";
|
import { SkeletonTable } from "../UI/Loading";
|
||||||
import { OrderFilters } from "./OrderFilters";
|
import { OrderFilters } from "./OrderFilters";
|
||||||
|
|
@ -17,6 +18,14 @@ const fmtDate = (d) => {
|
||||||
return `${day}.${m}.${y}`;
|
return `${day}.${m}.${y}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getShipmentIssues = (group) => {
|
||||||
|
const data = group?.driverShipmentData;
|
||||||
|
if (!Array.isArray(data) || data.length === 0) return null;
|
||||||
|
const unshipped = data.filter((i) => !i.shipped);
|
||||||
|
if (unshipped.length === 0) return null;
|
||||||
|
return unshipped;
|
||||||
|
};
|
||||||
|
|
||||||
const buildGroupSummary = (group) => {
|
const buildGroupSummary = (group) => {
|
||||||
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
||||||
const parts = [orderCountLabel];
|
const parts = [orderCountLabel];
|
||||||
|
|
@ -45,6 +54,12 @@ const renderOrderNumbers = (group) => {
|
||||||
return `${visible.join(", ")} +${remaining}`;
|
return `${visible.join(", ")} +${remaining}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDeliveryTypeLabel = (deliveryType) => {
|
||||||
|
if (deliveryType === "pickup") return "Самовывоз";
|
||||||
|
if (deliveryType === "delivery") return "Доставка";
|
||||||
|
return "—";
|
||||||
|
};
|
||||||
|
|
||||||
const renderMobileOrderNumbers = (group) => {
|
const renderMobileOrderNumbers = (group) => {
|
||||||
const numbers = group.allBillNumbers || group.orderNumbers;
|
const numbers = group.allBillNumbers || group.orderNumbers;
|
||||||
if (!Array.isArray(numbers) || !numbers.length) {
|
if (!Array.isArray(numbers) || !numbers.length) {
|
||||||
|
|
@ -79,7 +94,7 @@ export const OrdersTable = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-0">
|
<Panel className="p-0 fs-zone-table">
|
||||||
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -102,96 +117,139 @@ export const OrdersTable = ({
|
||||||
Группы не найдены. Попробуйте изменить поиск или статус.
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{orderGroups.map((group) => (
|
{orderGroups.map((group) => {
|
||||||
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
|
const baseClass = "w-full rounded-[22px] border text-left transition";
|
||||||
|
const selectedClass = selectedOrderGroupId === group.id
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||||
|
: hasProblem
|
||||||
|
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
|
||||||
|
|
||||||
|
const allNumbers = group.allBillNumbers || group.orderNumbers || [];
|
||||||
|
const primaryBill = allNumbers[0] || "—";
|
||||||
|
const totalCount = allNumbers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id}>
|
||||||
|
<div className="mb-1 flex items-center gap-2 px-1 text-xs">
|
||||||
|
<span className="font-medium text-[var(--color-text)]">№ {primaryBill}</span>
|
||||||
|
{totalCount > 1 && (
|
||||||
|
<span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
||||||
|
{totalCount} сч.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenOrder(group.id)}
|
onClick={() => onOpenOrder(group.id)}
|
||||||
className={[
|
className={[baseClass, selectedClass, "flex flex-col items-start justify-start p-4"].join(" ")}
|
||||||
"w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition",
|
|
||||||
selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="min-w-0 w-full font-medium leading-snug break-words">
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
|
||||||
<div className="min-w-0 truncate font-medium">
|
|
||||||
{group.displayTitle || group.customerName || group.groupKey}
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
</div>
|
</div>
|
||||||
<Badge className="self-start" tone={getOrderGroupStatusTone(group)}>
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${group.deliveryType === "pickup" ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" : "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"}`}>
|
||||||
|
{group.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
||||||
|
</span>
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||||
{getOrderGroupDisplayStatusLabel(group)}
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
{group.displaySubtitle || [group.customerPhone, group.customerDate].filter(Boolean).join(" · ")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div>
|
{group.hasDeliveryProblem && (
|
||||||
<div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderMobileOrderNumbers(group)}</div>
|
<div className="mt-2 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-2 py-1 text-xs">
|
||||||
<div className="mt-3 text-xs text-[var(--color-text-muted)]">
|
<span className="font-medium text-[var(--color-warning)]">⚠ Проблема с доставкой</span>
|
||||||
{formatDateTime(group.updatedAt)}
|
{group.deliveryProblemNote && (
|
||||||
|
<div className="mt-0.5 text-[var(--color-text-muted)]">{group.deliveryProblemNote}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<div className="hidden md:block">
|
||||||
{!orderGroups.length ? (
|
{!orderGroups.length ? (
|
||||||
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
|
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
|
||||||
Группы не найдены. Попробуйте изменить поиск или статус.
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="min-w-full border-collapse">
|
<div className="overflow-x-auto">
|
||||||
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
<div className="min-w-[1080px]">
|
||||||
<tr>
|
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
<th className="px-5 py-4 font-medium">Группа / Клиент</th>
|
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
|
||||||
<th className="px-5 py-4 font-medium">Счёта</th>
|
<div className="px-3 py-1.5 font-medium">Счета</div>
|
||||||
<th className="px-5 py-4 font-medium">Статус</th>
|
<div className="px-3 py-1.5 font-medium">Город</div>
|
||||||
<th className="px-5 py-4 font-medium">Водитель</th>
|
<div className="px-3 py-1.5 font-medium">Статус</div>
|
||||||
<th className="px-5 py-4 font-medium">Дата доставки</th>
|
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
|
||||||
<th className="px-5 py-4 font-medium">Обновлён</th>
|
<div className="px-3 py-1.5 font-medium">Тип</div>
|
||||||
</tr>
|
<div className="px-3 py-1.5 font-medium">Водитель</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
{orderGroups.map((group) => {
|
||||||
{orderGroups.map((group) => (
|
const hasProblem = group.hasDeliveryProblem;
|
||||||
<tr
|
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
|
||||||
|
hasProblem
|
||||||
|
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
|
||||||
|
: "hover:bg-[var(--color-accent-soft)]"
|
||||||
|
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
|
||||||
|
|
||||||
|
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
|
||||||
|
const primaryBill = billNumbers[0] || "—";
|
||||||
|
const totalBills = billNumbers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className={[
|
type="button"
|
||||||
"cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]",
|
className={rowClassName}
|
||||||
selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => onOpenOrder(group.id)}
|
onClick={() => onOpenOrder(group.id)}
|
||||||
>
|
>
|
||||||
<td className="px-5 py-4">
|
<div className="min-w-0 px-3 py-1.5">
|
||||||
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
{[group.customerName, group.customerPhone].filter(Boolean).join(" · ")}
|
{group.customerPhone || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">{group.groupKey}</div>
|
</div>
|
||||||
</td>
|
<div className="px-3 py-1.5">
|
||||||
<td className="max-w-[260px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
|
||||||
{renderOrderNumbers(group)}
|
{totalBills > 1 && (
|
||||||
</td>
|
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
|
||||||
<td className="px-5 py-4 text-center">
|
{totalBills} сч.
|
||||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
</span>
|
||||||
</td>
|
)}
|
||||||
<td className="px-5 py-4 text-sm">
|
</div>
|
||||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||||
</td>
|
{group.city || "—"}
|
||||||
<td className="px-5 py-4 text-sm">
|
</div>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||||
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 text-xs">
|
||||||
{group.deliveryDate ? (
|
{group.deliveryDate ? (
|
||||||
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[var(--color-text-muted)]">—</span>
|
<span className="text-[var(--color-text-muted)]">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
<div className="px-3 py-1.5 text-xs">
|
||||||
{formatDateTime(group.updatedAt)}
|
<span className="inline-flex items-center gap-1">
|
||||||
</td>
|
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||||
</tr>
|
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
|
||||||
))}
|
</span>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
<div className="px-3 py-1.5 text-xs">
|
||||||
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
/**
|
||||||
|
* @file SmsStatusCard.jsx
|
||||||
|
* @description SMS status + restart button for a specific delivery group.
|
||||||
|
* Shows: current notification status, when SMS was sent, countdown to next SMS,
|
||||||
|
* and a "Restart SMS" button that resets this group's notification status.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
// ── Status labels ────────────────────────────────────────────────────────────
|
||||||
|
const NOTIF_LABELS = {
|
||||||
|
not_started: "Не отправлено",
|
||||||
|
link_ready: "В очереди на отправку",
|
||||||
|
sms_sending: "Отправляется…",
|
||||||
|
first_sms_sent: "1-е SMS отправлено",
|
||||||
|
second_sms_sending: "2-я SMS отправляется…",
|
||||||
|
second_sms_sent: "2-е SMS отправлено",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
manual_required: "Требуется ручное управление",
|
||||||
|
paid_storage_sending: "Отправляется…",
|
||||||
|
paid_storage_sent: "Платное хранение: отправлено",
|
||||||
|
draft: "Черновик",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIF_TONES = {
|
||||||
|
not_started: "neutral",
|
||||||
|
link_ready: "warning",
|
||||||
|
sms_sending: "info",
|
||||||
|
first_sms_sent: "info",
|
||||||
|
second_sms_sending: "info",
|
||||||
|
second_sms_sent: "accent",
|
||||||
|
send_failed: "danger",
|
||||||
|
manual_required: "warning",
|
||||||
|
paid_storage_sending: "info",
|
||||||
|
paid_storage_sent: "accent",
|
||||||
|
draft: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
if (!ts) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch { return ts; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtCountdown = (targetTs) => {
|
||||||
|
if (!targetTs) return null;
|
||||||
|
const diff = new Date(targetTs).getTime() - Date.now();
|
||||||
|
if (diff <= 0) return "готово к отправке";
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
const s = Math.floor((diff % 60000) / 1000);
|
||||||
|
if (m >= 60) {
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const restM = m % 60;
|
||||||
|
return `${h}ч ${restM}м`;
|
||||||
|
}
|
||||||
|
return `${m}м ${s}с`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export const SmsStatusCard = ({ order, userRole }) => {
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const [restartDone, setRestartDone] = useState(false);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [smsLog, setSmsLog] = useState([]);
|
||||||
|
const [loadingLog, setLoadingLog] = useState(false);
|
||||||
|
|
||||||
|
// Tick every 1s for countdown
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load SMS log for this group
|
||||||
|
const loadSmsLog = useCallback(async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setLoadingLog(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("sms_campaign_log")
|
||||||
|
.select("*")
|
||||||
|
.eq("order_group_id", order.id)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(10);
|
||||||
|
if (error) throw error;
|
||||||
|
setSmsLog(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail — not critical
|
||||||
|
} finally {
|
||||||
|
setLoadingLog(false);
|
||||||
|
}
|
||||||
|
}, [order?.id]);
|
||||||
|
|
||||||
|
useEffect(() => { loadSmsLog(); }, [loadSmsLog]);
|
||||||
|
|
||||||
|
// Can manage?
|
||||||
|
const canManage = ["mega_admin", "admin"].includes(userRole);
|
||||||
|
|
||||||
|
const notifStatus = order.notificationStatus || order.notification_status || "not_started";
|
||||||
|
const nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at;
|
||||||
|
const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at;
|
||||||
|
const secondSmsAt = order.secondSmsSentAt || order.second_sms_sent_at;
|
||||||
|
const smsSentAt = order.smsSentAt || order.sms_sent_at;
|
||||||
|
const smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0;
|
||||||
|
const lastError = order.lastSmsError || order.last_sms_error;
|
||||||
|
|
||||||
|
// Restart: reset this group's notification_status to link_ready
|
||||||
|
const handleRestart = async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
// Reset this group to link_ready — restart SMS flow from beginning
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
notification_status: "link_ready",
|
||||||
|
sms_sent_at: null,
|
||||||
|
first_sms_sent_at: null,
|
||||||
|
second_sms_sent_at: null,
|
||||||
|
next_notification_check_at: null,
|
||||||
|
sms_attempts: 0,
|
||||||
|
last_sms_error: null,
|
||||||
|
})
|
||||||
|
.eq("id", order.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setRestartDone(true);
|
||||||
|
setTimeout(() => setRestartDone(false), 3000);
|
||||||
|
// Reload log
|
||||||
|
loadSmsLog();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Restart SMS error:", e);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restart second SMS only (keep first SMS sent)
|
||||||
|
const handleRestartSecond = async () => {
|
||||||
|
if (!order?.id) return;
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
notification_status: "first_sms_sent",
|
||||||
|
second_sms_sent_at: null,
|
||||||
|
next_notification_check_at: null,
|
||||||
|
sms_attempts: 0,
|
||||||
|
last_sms_error: null,
|
||||||
|
})
|
||||||
|
.eq("id", order.id);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
setRestartDone(true);
|
||||||
|
setTimeout(() => setRestartDone(false), 3000);
|
||||||
|
loadSmsLog();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Restart second SMS error:", e);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const countdown = nextCheck ? fmtCountdown(nextCheck) : null;
|
||||||
|
// Determine SMS display state
|
||||||
|
const hasSmsSent = !!smsSentAt; // SMS physically sent (may not be delivered yet)
|
||||||
|
const hasFirstSms = !!firstSmsAt; // First SMS confirmed delivered (103)
|
||||||
|
const hasSecondSms = !!secondSmsAt; // Second SMS confirmed delivered (103)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
|
||||||
|
<Badge tone={NOTIF_TONES[notifStatus] || "neutral"}>
|
||||||
|
{NOTIF_LABELS[notifStatus] || notifStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{/* 1st SMS */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : hasSmsSent && notifStatus === "sms_sending" ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[var(--color-text)]">1-е SMS</div>
|
||||||
|
{hasFirstSms ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} ✓ доставлено</div>
|
||||||
|
) : hasSmsSent && notifStatus === "sms_sending" ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(smsSentAt)} · отправлено, ждём подтверждения…</div>
|
||||||
|
) : notifStatus === "link_ready" ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[var(--color-text-muted)]">не отправлено</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2nd SMS */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[var(--color-text)]">2-е SMS</div>
|
||||||
|
{hasSecondSms ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsAt)}</div>
|
||||||
|
) : notifStatus === "first_sms_sent" && countdown ? (
|
||||||
|
<div className="text-[var(--color-text-muted)]">
|
||||||
|
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[var(--color-text-muted)]">—</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next check countdown */}
|
||||||
|
{countdown && notifStatus !== "second_sms_sent" && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5">
|
||||||
|
<span className="text-[var(--color-text-muted)]">⏱ Следующая проверка:</span>
|
||||||
|
<span className="font-mono text-[var(--color-accent)]">{countdown}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{lastError && (
|
||||||
|
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
|
||||||
|
⚠ {lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attempts */}
|
||||||
|
{smsAttempts > 0 && (
|
||||||
|
<div className="text-[var(--color-text-muted)]">Попыток отправки: {smsAttempts}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMS log for this group */}
|
||||||
|
{smsLog.length > 0 && (
|
||||||
|
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{smsLog.map((log) => (
|
||||||
|
<div key={log.id} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
|
||||||
|
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
|
||||||
|
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
|
||||||
|
</Badge>
|
||||||
|
{log.sent_to && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">→ {log.sent_to}</span>
|
||||||
|
)}
|
||||||
|
{log.sms_code && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">код: {log.sms_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Restart buttons */}
|
||||||
|
{canManage && (
|
||||||
|
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestart}
|
||||||
|
disabled={restarting}
|
||||||
|
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{restarting ? "Сброс…" : restartDone ? "✓ Сброшено" : "🔄 Перезапустить SMS"}
|
||||||
|
</button>
|
||||||
|
{hasFirstSms && !hasSecondSms && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRestartSecond}
|
||||||
|
disabled={restarting}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
⟳ Только 2-ю SMS
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const FontSettingsContext = createContext(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "supersam-font-settings";
|
||||||
|
|
||||||
|
// Categories: key → { label, defaultScale, min, max, step, description }
|
||||||
|
export const FONT_CATEGORIES = [
|
||||||
|
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
|
||||||
|
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
|
||||||
|
acc[cat.key] = cat.defaultScale;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Merge with defaults to handle new categories
|
||||||
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(settings) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const cat of FONT_CATEGORIES) {
|
||||||
|
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSettingsProvider = ({ children }) => {
|
||||||
|
const [settings, setSettings] = useState(loadSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applySettings(settings);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateCategory = (key, scale) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: scale }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
setSettings({ ...DEFAULT_SETTINGS });
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
settings,
|
||||||
|
updateCategory,
|
||||||
|
resetAll,
|
||||||
|
categories: FONT_CATEGORIES,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFontSettings = () => {
|
||||||
|
const context = useContext(FontSettingsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useFontSettings must be used within FontSettingsProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { assignDriverToOrderGroup, fetchOrderGroups, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
|
import { assignDriverToOrderGroup, fetchOrderGroups, saveShipmentData, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
|
||||||
import {
|
import {
|
||||||
buildOrderGroupBuckets,
|
buildOrderGroupBuckets,
|
||||||
filterOrderGroups,
|
filterOrderGroups,
|
||||||
|
|
@ -14,6 +14,7 @@ export const useOrderGroups = () => {
|
||||||
const [filters, setFilters] = React.useState({
|
const [filters, setFilters] = React.useState({
|
||||||
query: "",
|
query: "",
|
||||||
displayStatus: "all",
|
displayStatus: "all",
|
||||||
|
deliveryType: "",
|
||||||
});
|
});
|
||||||
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
@ -22,18 +23,15 @@ export const useOrderGroups = () => {
|
||||||
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
|
const [isSavingDriverAssignment, setIsSavingDriverAssignment] = React.useState(false);
|
||||||
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
|
const [isSavingStatusChange, setIsSavingStatusChange] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const loadLiveData = React.useCallback(async (showLoading = true) => {
|
||||||
let cancelled = false;
|
if (showLoading) {
|
||||||
|
|
||||||
const loadLiveData = async () => {
|
|
||||||
/* Demo mode removed — always use Supabase */
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
}
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
|
|
||||||
const groupsResult = await fetchOrderGroups();
|
const groupsResult = await fetchOrderGroups();
|
||||||
|
|
||||||
if (cancelled) {
|
if (!groupsResult) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,15 +44,29 @@ export const useOrderGroups = () => {
|
||||||
|
|
||||||
setOrderGroups(groupsResult.data || []);
|
setOrderGroups(groupsResult.data || []);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
|
||||||
|
|
||||||
loadLiveData();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const doLoad = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await loadLiveData(true);
|
||||||
|
if (cancelled) return;
|
||||||
|
};
|
||||||
|
doLoad();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [loadLiveData]);
|
||||||
|
|
||||||
|
// Refetch on window focus to keep data fresh across tabs/pages
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
loadLiveData(false); // silent refetch, no loading spinner
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", handleFocus);
|
||||||
|
return () => window.removeEventListener("focus", handleFocus);
|
||||||
|
}, [loadLiveData]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!orderGroups.length) {
|
if (!orderGroups.length) {
|
||||||
setSelectedOrderGroupId(null);
|
setSelectedOrderGroupId(null);
|
||||||
|
|
@ -171,10 +183,10 @@ export const useOrderGroups = () => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details }) => {
|
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status, details, shipmentData }) => {
|
||||||
setIsSavingStatusChange(true);
|
setIsSavingStatusChange(true);
|
||||||
try {
|
try {
|
||||||
const result = await updateDeliveryStatus({ orderGroupId, status, details });
|
const result = await updateDeliveryStatus({ orderGroupId, status, details, shipmentData });
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -195,6 +207,27 @@ export const useOrderGroups = () => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onSaveShipmentData = React.useCallback(async ({ orderGroupId, shipmentData }) => {
|
||||||
|
try {
|
||||||
|
const result = await saveShipmentData({ orderGroupId, shipmentData });
|
||||||
|
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, "Не удалось сохранить данные отгрузки"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orderGroups,
|
orderGroups,
|
||||||
allOrderGroups: orderGroups,
|
allOrderGroups: orderGroups,
|
||||||
|
|
@ -211,10 +244,12 @@ export const useOrderGroups = () => {
|
||||||
saveManualDeliveryChoice,
|
saveManualDeliveryChoice,
|
||||||
assignDriver,
|
assignDriver,
|
||||||
changeDeliveryStatus,
|
changeDeliveryStatus,
|
||||||
|
saveShipmentData: onSaveShipmentData,
|
||||||
isSavingDeliveryChoice,
|
isSavingDeliveryChoice,
|
||||||
isSavingDriverAssignment,
|
isSavingDriverAssignment,
|
||||||
isSavingStatusChange,
|
isSavingStatusChange,
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
|
refetch: loadLiveData,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ROLE_LABELS } from "../constants/roles";
|
import { ROLE_LABELS } from "../constants/roles";
|
||||||
import { Badge } from "../components/UI/Badge";
|
import { Badge } from "../components/UI/Badge";
|
||||||
import { Button } from "../components/UI/Button";
|
import { Button } from "../components/UI/Button";
|
||||||
|
|
@ -28,6 +29,7 @@ export const AppShell = ({
|
||||||
}) => {
|
}) => {
|
||||||
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
||||||
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
const [showNotifSettings, setShowNotifSettings] = React.useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (showNotifSettings) {
|
if (showNotifSettings) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,7 +49,7 @@ export const AppShell = ({
|
||||||
<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 */}
|
{/* Desktop sidebar */}
|
||||||
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
|
<Panel className="fs-zone-nav 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)]">
|
||||||
Панель
|
Панель
|
||||||
|
|
@ -57,8 +59,9 @@ export const AppShell = ({
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<Button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
variant="ghost"
|
||||||
className={[
|
className={[
|
||||||
"flex w-full items-center justify-between rounded-[18px] px-3 py-3 text-left text-sm transition",
|
"flex w-full items-center justify-between rounded-[18px] px-3 py-3 text-left text-sm transition",
|
||||||
activeSection === item.key
|
activeSection === item.key
|
||||||
|
|
@ -70,7 +73,7 @@ export const AppShell = ({
|
||||||
>
|
>
|
||||||
<span className="font-medium">{item.label}</span>
|
<span className="font-medium">{item.label}</span>
|
||||||
{item.badge ? <Badge tone="accent">{item.badge}</Badge> : null}
|
{item.badge ? <Badge tone="accent">{item.badge}</Badge> : null}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -80,6 +83,9 @@ export const AppShell = ({
|
||||||
{isGuideOpen ? "К рабочей области" : "Справка"}
|
{isGuideOpen ? "К рабочей области" : "Справка"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
|
||||||
|
Настройки
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -87,9 +93,9 @@ export const AppShell = ({
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0">
|
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<Panel className="p-4 xl:hidden">
|
<Panel className="p-4 xl:hidden fs-zone-heading">
|
||||||
<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">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
|
@ -116,6 +122,9 @@ export const AppShell = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
||||||
|
⚙
|
||||||
|
</Button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
|
|
@ -126,11 +135,12 @@ export const AppShell = ({
|
||||||
|
|
||||||
{/* Mobile tab navigation — STICKY TOP */}
|
{/* Mobile tab navigation — STICKY TOP */}
|
||||||
{shouldShowMobileNav && (
|
{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="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 fs-zone-nav">
|
||||||
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
|
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<Button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
variant="ghost"
|
||||||
className={[
|
className={[
|
||||||
"flex flex-shrink-0 items-center gap-1.5 rounded-[14px] px-3 py-2 text-sm transition",
|
"flex flex-shrink-0 items-center gap-1.5 rounded-[14px] px-3 py-2 text-sm transition",
|
||||||
activeSection === item.key
|
activeSection === item.key
|
||||||
|
|
@ -144,14 +154,14 @@ export const AppShell = ({
|
||||||
{item.badge ? (
|
{item.badge ? (
|
||||||
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
|
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop header */}
|
{/* Desktop header */}
|
||||||
<Panel className="hidden p-4 md:p-5 xl:block">
|
<Panel className="hidden p-4 md:p-5 xl:block fs-zone-heading">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
|
@ -182,6 +192,9 @@ export const AppShell = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
|
||||||
|
⚙
|
||||||
|
</Button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ 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 { FontSettingsProvider } from "./context/FontSettingsContext";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { initErrorLogging } from "./utils/errorLogger";
|
import { initErrorLogging } from "./utils/errorLogger";
|
||||||
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "./styles/fontSettings.css";
|
||||||
|
|
||||||
registerPwaServiceWorker();
|
registerPwaServiceWorker();
|
||||||
initErrorLogging();
|
initErrorLogging();
|
||||||
|
|
@ -15,9 +17,11 @@ initErrorLogging();
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<FontSettingsProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
</FontSettingsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|
@ -7,6 +7,7 @@ import { OrderCompositionPanel } from "../components/client/OrderCompositionPane
|
||||||
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";
|
||||||
|
|
||||||
import { Skeleton } from "../components/UI/Loading";
|
import { Skeleton } from "../components/UI/Loading";
|
||||||
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,9 +42,25 @@ const addDaysToDateKey = (dateKey, amount) => {
|
||||||
return baseDate.toISOString().slice(0, 10);
|
return baseDate.toISOString().slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSundayKey = (dateKey) => {
|
||||||
|
if (!dateKey) return true;
|
||||||
|
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||||
|
return d.getUTCDay() === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextWorkdayKey = (dateKey) => {
|
||||||
|
let next = addDaysToDateKey(dateKey, 1);
|
||||||
|
while (isSundayKey(next)) {
|
||||||
|
next = addDaysToDateKey(next, 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
||||||
const todayKey = getBusinessTodayKey(referenceDate);
|
const todayKey = getBusinessTodayKey(referenceDate);
|
||||||
return new Set([addDaysToDateKey(todayKey, 1), addDaysToDateKey(todayKey, 2)].filter(Boolean));
|
const firstWorkday = getNextWorkdayKey(todayKey);
|
||||||
|
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||||
|
return new Set([firstWorkday, secondWorkday].filter(Boolean));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => {
|
const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => {
|
||||||
|
|
@ -64,7 +81,16 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
||||||
const deliveryTime = invitation.deliveryTime;
|
const deliveryTime = invitation.deliveryTime;
|
||||||
|
|
||||||
if (!rawSlots.length && !deliveryDate) {
|
if (!rawSlots.length && !deliveryDate) {
|
||||||
return [];
|
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday)
|
||||||
|
const todayKey = getBusinessTodayKey(referenceDate);
|
||||||
|
const firstWorkday = getNextWorkdayKey(todayKey);
|
||||||
|
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||||
|
return [
|
||||||
|
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||||
|
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||||
|
{ id: `slot-${secondWorkday}-first`, date: secondWorkday, time: "Первая половина дня" },
|
||||||
|
{ id: `slot-${secondWorkday}-second`, date: secondWorkday, time: "Вторая половина дня" },
|
||||||
|
].filter((s) => s.date);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rawSlots.length && deliveryDate) {
|
if (!rawSlots.length && deliveryDate) {
|
||||||
|
|
@ -195,7 +221,10 @@ export const ClientDeliveryPage = () => {
|
||||||
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
||||||
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
||||||
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
||||||
const referenceDate = React.useMemo(() => new Date(), [token]);
|
const referenceDate = React.useMemo(
|
||||||
|
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
|
||||||
|
[token, invitation?.smsSentAt],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -378,6 +407,13 @@ export const ClientDeliveryPage = () => {
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.
|
Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.
|
||||||
</p>
|
</p>
|
||||||
|
{(invitation?.pickupCode || invitation?.pickup_code) ? (
|
||||||
|
<div className="mt-3 rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Код выдачи</p>
|
||||||
|
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -440,6 +476,22 @@ export const ClientDeliveryPage = () => {
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === TAB_DELIVERY && !invitation?.isPayedShip ? (
|
||||||
|
<div className="rounded-2xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-lg">💰</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-[var(--color-warning)]">
|
||||||
|
Стоимость доставки не оплачена
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Стоимость доставки будет рассчитана отдельно и согласована с логистом после выбора даты и времени.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeTab === TAB_DELIVERY && slots.length ? (
|
{activeTab === TAB_DELIVERY && slots.length ? (
|
||||||
<DeliverySlotsPicker
|
<DeliverySlotsPicker
|
||||||
slots={slots}
|
slots={slots}
|
||||||
|
|
@ -456,6 +508,14 @@ export const ClientDeliveryPage = () => {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === TAB_PICKUP && (invitation?.pickupCode || invitation?.pickup_code) ? (
|
||||||
|
<div className="rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Код выдачи</p>
|
||||||
|
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeTab === TAB_DELIVERY && !slots.length ? (
|
{activeTab === TAB_DELIVERY && !slots.length ? (
|
||||||
<Panel className="p-5 sm:p-6">
|
<Panel className="p-5 sm:p-6">
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Нет доступных слотов для выбора доставки.</p>
|
<p className="text-sm text-[var(--color-text-muted)]">Нет доступных слотов для выбора доставки.</p>
|
||||||
|
|
|
||||||
|
|
@ -187,4 +187,28 @@ describe("ClientDeliveryPage helpers", () => {
|
||||||
"По этому заказу согласование доставки завершено или передано логисту.",
|
"По этому заказу согласование доставки завершено или передано логисту.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips Sunday when generating fallback delivery slots from Friday", () => {
|
||||||
|
// 2026-04-17 is Friday → next workday = Saturday 18, then Monday 20 (skip Sunday 19)
|
||||||
|
const slots = groupSlotsFromInvitation(
|
||||||
|
{ availableSlots: [], deliveryDate: null },
|
||||||
|
new Date("2026-04-17T09:00:00Z"),
|
||||||
|
);
|
||||||
|
const dates = [...new Set(slots.map((s) => s.date))];
|
||||||
|
expect(dates).not.toContain("2026-04-19"); // Sunday
|
||||||
|
expect(dates).toContain("2026-04-18"); // Saturday
|
||||||
|
expect(dates).toContain("2026-04-20"); // Monday
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips Sunday when generating fallback delivery slots from Saturday", () => {
|
||||||
|
// 2026-04-18 is Saturday → next workday = Monday 20, then Tuesday 21
|
||||||
|
const slots = groupSlotsFromInvitation(
|
||||||
|
{ availableSlots: [], deliveryDate: null },
|
||||||
|
new Date("2026-04-18T09:00:00Z"),
|
||||||
|
);
|
||||||
|
const dates = [...new Set(slots.map((s) => s.date))];
|
||||||
|
expect(dates).not.toContain("2026-04-19"); // Sunday
|
||||||
|
expect(dates).toContain("2026-04-20"); // Monday
|
||||||
|
expect(dates).toContain("2026-04-21"); // Tuesday
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,16 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate, useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
import { Navigate, useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
|
import { isOrderGroupVisibleToDriver } from "../services/orderGroupViews";
|
||||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
import { OrdersTable } from "../components/orders/OrdersTable";
|
|
||||||
import { AdminDashboard } from "../components/admin/AdminDashboard";
|
import { AdminDashboard } from "../components/admin/AdminDashboard";
|
||||||
import UserManagementPanel from "../components/admin/UserManagementPanel";
|
import UserManagementPanel from "../components/admin/UserManagementPanel";
|
||||||
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
import ErrorLogPanel from "../components/admin/ErrorLogPanel";
|
||||||
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
||||||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||||
|
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
@ -33,6 +35,7 @@ const MEGA_ADMIN_NAV = [
|
||||||
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
|
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Role → Default Section Map ─────────────────────────────────────────────
|
// ── Role → Default Section Map ─────────────────────────────────────────────
|
||||||
|
|
@ -108,6 +111,16 @@ export const DashboardPage = () => {
|
||||||
return [...set].sort();
|
return [...set].sort();
|
||||||
}, [allOrderGroups]);
|
}, [allOrderGroups]);
|
||||||
|
|
||||||
|
// ── Driver order count (for badge) ────────────────────────────────────────
|
||||||
|
const driverOrderCount = React.useMemo(() => {
|
||||||
|
if (userRole !== "driver" || !user) return 0;
|
||||||
|
return allOrderGroups.filter((g) => {
|
||||||
|
const isVisible = isOrderGroupVisibleToDriver(g);
|
||||||
|
const isAssignedToMe = g.assignedDriverId === user.id;
|
||||||
|
return isVisible && isAssignedToMe;
|
||||||
|
}).length;
|
||||||
|
}, [allOrderGroups, user, userRole]);
|
||||||
|
|
||||||
// ── Navigation Builder ────────────────────────────────────────────────────
|
// ── Navigation Builder ────────────────────────────────────────────────────
|
||||||
const openGroupPage = React.useCallback((groupId) => {
|
const openGroupPage = React.useCallback((groupId) => {
|
||||||
navigate("/dashboard/group/" + groupId);
|
navigate("/dashboard/group/" + groupId);
|
||||||
|
|
@ -130,7 +143,7 @@ export const DashboardPage = () => {
|
||||||
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
{ key: "logistics", label: "Логистика", description: "Группы доставки по готовности к уведомлению.", badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: section.key, label: section.label, description: section.description, badge: String(allOrderGroups.length || orderGroups.length || 0) },
|
{ key: section.key, label: section.label, description: section.description, badge: String(userRole === "driver" ? driverOrderCount : (allOrderGroups.length || orderGroups.length || 0)) },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложить улучшение.", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -163,6 +176,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></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 (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
||||||
|
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
|
|
@ -178,25 +192,12 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (userRole === "logistician") {
|
// All roles (logistician, admin, mega_admin, manager) — same grouped board
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
|
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupPage} statusOptions={statusOptions} isLoading={isLoading} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 xl:space-y-8">
|
|
||||||
<OrdersTable orderGroups={filteredOrderGroups} selectedOrderGroupId={selectedOrderGroupId} onOpenOrder={openGroupPage} filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} isLoading={isLoading} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Layout ────────────────────────────────────────────────────────────────
|
// ── Layout ────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ export const GroupDetailPage = () => {
|
||||||
isSavingStatusChange,
|
isSavingStatusChange,
|
||||||
assignDriver,
|
assignDriver,
|
||||||
changeDeliveryStatus,
|
changeDeliveryStatus,
|
||||||
|
saveShipmentData,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
refetch,
|
||||||
} = useOrderGroups();
|
} = useOrderGroups();
|
||||||
|
|
||||||
// ── Drivers ────────────────────────────────────────────────────────────────
|
// ── Drivers ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -47,6 +49,11 @@ export const GroupDetailPage = () => {
|
||||||
}
|
}
|
||||||
}, [groupId, setSelectedOrderGroupId]);
|
}, [groupId, setSelectedOrderGroupId]);
|
||||||
|
|
||||||
|
// Refetch data when navigating to this page to ensure fresh status
|
||||||
|
React.useEffect(() => {
|
||||||
|
refetch(false); // silent refetch, no loading spinner
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
|
@ -111,6 +118,7 @@ export const GroupDetailPage = () => {
|
||||||
drivers={drivers}
|
drivers={drivers}
|
||||||
onAssignDriver={assignDriver}
|
onAssignDriver={assignDriver}
|
||||||
onChangeDeliveryStatus={changeDeliveryStatus}
|
onChangeDeliveryStatus={changeDeliveryStatus}
|
||||||
|
onSaveShipmentData={saveShipmentData}
|
||||||
userRole={userRole}
|
userRole={userRole}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
/**
|
||||||
|
* @file SettingsPage.jsx
|
||||||
|
* @description Page for user settings: font size controls for all UI categories.
|
||||||
|
* Settings persist to localStorage via FontSettingsContext.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
|
||||||
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { Button } from "../components/UI/Button";
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
|
||||||
|
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
|
||||||
|
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
|
||||||
|
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Slider = ({ category, value, onChange }) => {
|
||||||
|
const pct = ((value - category.min) / (category.max - category.min)) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={category.min}
|
||||||
|
max={category.max}
|
||||||
|
step={category.step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
|
className="fs-slider flex-1"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
|
||||||
|
}}
|
||||||
|
aria-label={category.label}
|
||||||
|
/>
|
||||||
|
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
|
||||||
|
{value.toFixed(2)}×
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsPage = () => {
|
||||||
|
const { settings, updateCategory, resetAll, categories } = useFontSettings();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const applyPreset = (scales) => {
|
||||||
|
for (const [key, scale] of Object.entries(scales)) {
|
||||||
|
updateCategory(key, scale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if current settings match a preset
|
||||||
|
const activePreset = PRESETS.findIndex((p) =>
|
||||||
|
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
|
||||||
|
Настройки интерфейса
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||||
|
← Назад
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<Panel className="p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Быстрые пресеты
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(preset.scales)}
|
||||||
|
className={[
|
||||||
|
"rounded-[18px] border-2 px-4 py-3 text-center transition",
|
||||||
|
activePreset === i
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{preset.label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Font size sliders */}
|
||||||
|
<Panel className="p-5 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Размеры шрифтов
|
||||||
|
</h2>
|
||||||
|
<Button size="sm" variant="ghost" onClick={resetAll}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Slider
|
||||||
|
key={cat.key}
|
||||||
|
category={cat}
|
||||||
|
value={settings[cat.key] ?? cat.defaultScale}
|
||||||
|
onChange={(v) => updateCategory(cat.key, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
|
||||||
|
<div className="fs-zone-heading space-y-1">
|
||||||
|
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
|
||||||
|
Заголовок секции
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-nav flex gap-2">
|
||||||
|
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
|
||||||
|
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
||||||
|
Пункт меню
|
||||||
|
</span>
|
||||||
|
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
|
||||||
|
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
|
||||||
|
Вкладка
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[var(--color-surface)]">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-t border-[var(--color-border)]">
|
||||||
|
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
|
||||||
|
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
|
||||||
|
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
|
||||||
|
Текст в карточке — описание доставки или заказа.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
|
||||||
|
Основной текст интерфейса.
|
||||||
|
</p>
|
||||||
|
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
|
||||||
|
мелкая подпись · временная отметка
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-[var(--color-text-muted)]">
|
||||||
|
Настройки сохраняются на этом устройстве
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
import { Navigate, createBrowserRouter, Outlet } 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";
|
||||||
|
|
@ -7,6 +7,28 @@ 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";
|
||||||
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
import { ForbiddenPage } from "./pages/ForbiddenPage";
|
||||||
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
|
import { useAuth } from "./context/AuthContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protects routes that require authentication.
|
||||||
|
* Redirects to /login with return URL if no user and session is loaded.
|
||||||
|
* Shows nothing while session is being restored (avoids flash-of-login).
|
||||||
|
*/
|
||||||
|
const RequireAuth = ({ children }) => {
|
||||||
|
const { user, isSessionLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isSessionLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
|
return <Navigate to={`/login?redirect=${redirect}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -31,11 +53,27 @@ export const router = createBrowserRouter([
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "dashboard",
|
path: "dashboard",
|
||||||
element: <DashboardPage />,
|
element: (
|
||||||
|
<RequireAuth>
|
||||||
|
<DashboardPage />
|
||||||
|
</RequireAuth>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "dashboard/group/:groupId",
|
path: "dashboard/group/:groupId",
|
||||||
element: <GroupDetailPage />,
|
element: (
|
||||||
|
<RequireAuth>
|
||||||
|
<GroupDetailPage />
|
||||||
|
</RequireAuth>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
element: (
|
||||||
|
<RequireAuth>
|
||||||
|
<SettingsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,14 @@ export const NOTIFICATION_STATUS_LABELS = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||||||
|
"agreed",
|
||||||
"driver_assigned",
|
"driver_assigned",
|
||||||
"loaded",
|
"loaded",
|
||||||
"on_route",
|
"on_route",
|
||||||
"problem",
|
"problem",
|
||||||
"delivered",
|
"delivered",
|
||||||
"picked_up",
|
"picked_up",
|
||||||
|
"paid_storage",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
|
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "loaded", "on_route", "problem"];
|
||||||
|
|
@ -139,7 +141,7 @@ export const isOrderGroupAgreedForDelivery = (group) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
||||||
DELIVERY_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
|
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
||||||
|
|
||||||
export const getOrderGroupDisplayStatusLabel = (group) => {
|
export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
@ -205,6 +207,18 @@ export const parseGroupDate = (value) => {
|
||||||
return new Date(Date.UTC(Number(`20${year}`), Number(month) - 1, Number(day)));
|
return new Date(Date.UTC(Number(`20${year}`), Number(month) - 1, Number(day)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||||
|
if (fullDateMatch) {
|
||||||
|
const [, day, month, year] = fullDateMatch;
|
||||||
|
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotSeparatedMatch = normalized.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||||
|
if (dotSeparatedMatch) {
|
||||||
|
const [, day, month, year] = dotSeparatedMatch;
|
||||||
|
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = new Date(normalized);
|
const parsed = new Date(normalized);
|
||||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
};
|
};
|
||||||
|
|
@ -303,6 +317,11 @@ export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deliveryTypeFilter = (filters.deliveryType || "").trim().toLowerCase();
|
||||||
|
if (deliveryTypeFilter && group.deliveryType !== deliveryTypeFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -316,6 +335,9 @@ export const ORDER_GROUP_STATUS_LABELS = {
|
||||||
sms_sent: "SMS отправлены",
|
sms_sent: "SMS отправлены",
|
||||||
manual_work: "Нужна ручная работа",
|
manual_work: "Нужна ручная работа",
|
||||||
ready_to_launch: "Готово к запуску",
|
ready_to_launch: "Готово к запуску",
|
||||||
|
first_sms_sent: "1-е приглашение отправлено",
|
||||||
|
second_sms_sent: "2-е приглашение отправлено",
|
||||||
|
draft: "Черновик",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
||||||
|
|
@ -331,13 +353,16 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
||||||
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
|
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
|
||||||
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
||||||
{ value: "status:manual_required", label: "Требует ручной обработки" },
|
{ value: "status:manual_required", label: "Требует ручной обработки" },
|
||||||
{ value: "status:second_sms_sent", label: "Повторное SMS" },
|
|
||||||
{ value: "status:sms_sent", label: "SMS отправлены" },
|
|
||||||
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
||||||
|
{ value: "status:ready_to_launch", label: "Готово к запуску" },
|
||||||
|
{ value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },
|
||||||
|
{ value: "status:second_sms_sent", label: NOTIFICATION_STATUS_LABELS.second_sms_sent },
|
||||||
|
{ value: "status:send_failed", label: NOTIFICATION_STATUS_LABELS.send_failed },
|
||||||
|
{ value: "status:link_ready", label: NOTIFICATION_STATUS_LABELS.link_ready },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getOrderGroupStatusLabel = (status) =>
|
export const getOrderGroupStatusLabel = (status) =>
|
||||||
ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
|
ORDER_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
||||||
|
|
||||||
export const getOrderGroupDeliveryStatusTone = (status) => {
|
export const getOrderGroupDeliveryStatusTone = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -459,17 +484,24 @@ export const buildOrderGroupBuckets = (groups) => {
|
||||||
export const getOrderGroupStatusTone = (group) => {
|
export const getOrderGroupStatusTone = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
|
// Highlight groups with delivery problems
|
||||||
|
if (group?.hasDeliveryProblem) return "warning";
|
||||||
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
||||||
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
const groupStatus = group?.status;
|
||||||
if (notificationStatus === "send_failed" || notificationStatus === "manual_required") {
|
if (notificationStatus === "send_failed" || notificationStatus === "manual_required") {
|
||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") {
|
if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") {
|
||||||
return "accent";
|
return "accent";
|
||||||
}
|
}
|
||||||
|
if (groupStatus === "first_sms_sent" || groupStatus === "second_sms_sent") {
|
||||||
|
return "accent";
|
||||||
|
}
|
||||||
|
|
||||||
if (group.smsSentAt) {
|
if (group.smsSentAt) {
|
||||||
return "accent";
|
return "accent";
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,16 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
// Also treat address equal to "САМОВЫВОЗ" as pickup indicator
|
// Also treat address equal to "САМОВЫВОЗ" as pickup indicator
|
||||||
const isPickupAddress = deliveryAddress.toUpperCase() === "САМОВЫВОЗ";
|
const isPickupAddress = deliveryAddress.toUpperCase() === "САМОВЫВОЗ";
|
||||||
|
|
||||||
// Resolve effective delivery type: DB field takes precedence, but if it says "delivery"
|
// Resolve effective delivery type:
|
||||||
// while source data clearly indicates pickup, treat as pickup
|
// - If DB explicitly says "pickup" → pickup
|
||||||
const effectiveDeliveryType = (row.delivery_type === "pickup" || deliveryStatus === "pickup" || isPickupFromSource || isPickupAddress)
|
// - If status is "pickup" → pickup
|
||||||
|
// - If DB explicitly says "delivery" (manually confirmed by logistician) → honor it, don't override
|
||||||
|
// - Otherwise fall back to source/address detection
|
||||||
|
const effectiveDeliveryType = (row.delivery_type === "pickup" || deliveryStatus === "pickup")
|
||||||
|
? "pickup"
|
||||||
|
: row.delivery_type === "delivery"
|
||||||
|
? "delivery"
|
||||||
|
: (isPickupFromSource || isPickupAddress)
|
||||||
? "pickup"
|
? "pickup"
|
||||||
: (row.delivery_type || "delivery");
|
: (row.delivery_type || "delivery");
|
||||||
|
|
||||||
|
|
@ -214,6 +221,19 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
pickupDate: row.pickup_date || null,
|
pickupDate: row.pickup_date || null,
|
||||||
pickupTimeSlot: row.pickup_time_slot || null,
|
pickupTimeSlot: row.pickup_time_slot || null,
|
||||||
driverShipmentData: row.driver_shipment_data || null,
|
driverShipmentData: row.driver_shipment_data || null,
|
||||||
|
hasDeliveryProblem: row.has_delivery_problem || false,
|
||||||
|
deliveryProblemNote: row.delivery_problem_note || null,
|
||||||
|
pickupCode: row.pickup_code || null,
|
||||||
|
syncedTo1cAt: row.synced_to_1c_at || null,
|
||||||
|
deliveryLink: row.delivery_link || null,
|
||||||
|
managerName: normalizeText(row.manager_name) || null,
|
||||||
|
managerEmail: normalizeText(row.manager_email) || null,
|
||||||
|
managerTel: normalizeText(row.manager_tel) || null,
|
||||||
|
payedShip: row.payed_ship || null,
|
||||||
|
isPayedShip: row.is_payed_ship || false,
|
||||||
|
invitationOpenedAt: row.delivery_invitation?.opened_at || null,
|
||||||
|
invitationAccessCount: row.delivery_invitation?.access_count || 0,
|
||||||
|
invitationLastAccessedAt: row.delivery_invitation?.last_accessed_at || null,
|
||||||
deliveryHalfDay: getOrderGroupDeliveryHalfDay({
|
deliveryHalfDay: getOrderGroupDeliveryHalfDay({
|
||||||
deliveryHalfDay: rawDeliveryHalfDay,
|
deliveryHalfDay: rawDeliveryHalfDay,
|
||||||
deliveryTime: rawDeliveryTime,
|
deliveryTime: rawDeliveryTime,
|
||||||
|
|
@ -254,7 +274,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER_GROUP_SELECT_FIELDS = `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), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot`;
|
const ORDER_GROUP_SELECT_FIELDS = `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, delivery_link_code, 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), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at)`;
|
||||||
|
|
||||||
export const updateOrderGroupDeliveryChoice = async ({
|
export const updateOrderGroupDeliveryChoice = async ({
|
||||||
orderGroupId,
|
orderGroupId,
|
||||||
|
|
@ -280,6 +300,8 @@ export const updateOrderGroupDeliveryChoice = async ({
|
||||||
if (deliveryType === "pickup") {
|
if (deliveryType === "pickup") {
|
||||||
updatePayload.pickup_date = pickupDate || null;
|
updatePayload.pickup_date = pickupDate || null;
|
||||||
updatePayload.pickup_time_slot = pickupTimeSlot || null;
|
updatePayload.pickup_time_slot = pickupTimeSlot || null;
|
||||||
|
// Pickup orders don't need a driver — clear assignment
|
||||||
|
updatePayload.assigned_driver_id = null;
|
||||||
} else {
|
} else {
|
||||||
updatePayload.pickup_date = null;
|
updatePayload.pickup_date = null;
|
||||||
updatePayload.pickup_time_slot = null;
|
updatePayload.pickup_time_slot = null;
|
||||||
|
|
@ -340,7 +362,7 @@ export const assignDriverToOrderGroup = async ({
|
||||||
const ADVANCED_STATUSES = ["loaded", "on_route", "delivered", "picked_up"];
|
const ADVANCED_STATUSES = ["loaded", "on_route", "delivered", "picked_up"];
|
||||||
const newStatus = driverId
|
const newStatus = driverId
|
||||||
? (ADVANCED_STATUSES.includes(currentStatus) ? undefined : "driver_assigned")
|
? (ADVANCED_STATUSES.includes(currentStatus) ? undefined : "driver_assigned")
|
||||||
: null; // removing driver → reset to pending
|
: "agreed"; // removing driver → revert to agreed (NOT null — would violate NOT NULL constraint)
|
||||||
const updates = {
|
const updates = {
|
||||||
assigned_driver_id: driverId || null,
|
assigned_driver_id: driverId || null,
|
||||||
...(newStatus !== undefined && { delivery_status: newStatus }),
|
...(newStatus !== undefined && { delivery_status: newStatus }),
|
||||||
|
|
@ -378,7 +400,55 @@ export const assignDriverToOrderGroup = async ({
|
||||||
}, "Ошибка назначения водителя");
|
}, "Ошибка назначения водителя");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateDeliveryStatus = async ({ orderGroupId, status, details } = {}) => {
|
export const saveShipmentData = async ({ orderGroupId, shipmentData }) => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const hasAnyShipped = shipmentData.some((i) => i.shipped);
|
||||||
|
const hasProblem = shipmentData.some((i) => !i.shipped);
|
||||||
|
const problemNote = hasProblem
|
||||||
|
? shipmentData.filter((i) => !i.shipped).map((i) => `${i.name}${i.quantity ? ` (${i.quantity}${i.unit ? ` ${i.unit}` : ""})` : ""}${i.comment ? ` — ${i.comment}` : ""}`).join("; ")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Any shipment data saved = delivered status. Problems go into has_delivery_problem/delivery_problem_note columns.
|
||||||
|
const newDeliveryStatus = hasAnyShipped ? "delivered" : undefined;
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
driver_shipment_data: shipmentData,
|
||||||
|
has_delivery_problem: hasProblem,
|
||||||
|
delivery_problem_note: problemNote,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (newDeliveryStatus) {
|
||||||
|
updatePayload.delivery_status = newDeliveryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.update(updatePayload)
|
||||||
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.select(ORDER_GROUP_SELECT_FIELDS)
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
await logAction({
|
||||||
|
orderGroupId,
|
||||||
|
action: "shipment_data_saved",
|
||||||
|
newValue: "shipment_data_updated",
|
||||||
|
details: { itemCount: shipmentData?.length || 0 },
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return mapOrderGroupRowToDeliveryGroup(data);
|
||||||
|
}, "Ошибка сохранения данных отгрузки");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDeliveryStatus = async ({ orderGroupId, status, details, shipmentData } = {}) => {
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
|
|
||||||
|
|
@ -428,6 +498,22 @@ export const updateDeliveryStatus = async ({ orderGroupId, status, details } = {
|
||||||
if (rpcError) throw rpcError;
|
if (rpcError) throw rpcError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save shipment data if provided (e.g. partial delivery info)
|
||||||
|
if (shipmentData) {
|
||||||
|
const { error: shipmentUpdateError } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
driver_shipment_data: shipmentData,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
|
if (shipmentUpdateError) {
|
||||||
|
// Log but don't fail the status update
|
||||||
|
console.error("[updateDeliveryStatus] Failed to save shipment data:", shipmentUpdateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch updated group
|
// Fetch updated group
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/* Font size scale variables — applied by FontSettingsContext */
|
||||||
|
:root {
|
||||||
|
--fs-scale-table: 1.0;
|
||||||
|
--fs-scale-card: 1.0;
|
||||||
|
--fs-scale-nav: 1.0;
|
||||||
|
--fs-scale-heading: 1.0;
|
||||||
|
--fs-scale-body: 1.0;
|
||||||
|
--fs-scale-small: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider styling */
|
||||||
|
.fs-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 3px solid var(--color-surface-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: 3px solid var(--color-surface-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Zone overrides ──────────────────────────────────────────────────────
|
||||||
|
.fs-zone-* wrappers override Tailwind text-* classes inside them.
|
||||||
|
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
|
||||||
|
Body zone FIRST — specific zones (table/card/nav/heading) come after
|
||||||
|
and win at equal specificity when nested inside body.
|
||||||
|
This lets us scale fonts without patching every component.
|
||||||
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Body zone — FIRST so specific zones inside body win at equal specificity */
|
||||||
|
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
|
||||||
|
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }
|
||||||
|
|
||||||
|
/* Table zone */
|
||||||
|
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
|
||||||
|
/* Arbitrary pixel sizes used in tables */
|
||||||
|
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
|
||||||
|
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
|
||||||
|
|
||||||
|
/* Card zone */
|
||||||
|
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
|
||||||
|
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
|
||||||
|
|
||||||
|
/* Nav zone */
|
||||||
|
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
|
||||||
|
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
|
||||||
|
|
||||||
|
/* Heading zone */
|
||||||
|
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
|
||||||
|
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
|
||||||
|
|
||||||
|
/* Small text zone */
|
||||||
|
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
||||||
|
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
|
||||||
|
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
|
||||||
|
|
@ -137,8 +137,22 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstDay = formatCrimeaDate(addDays(now, 1));
|
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
|
||||||
const secondDay = formatCrimeaDate(addDays(now, 2));
|
const isSunday = (date: Date) => date.getUTCDay() === 0;
|
||||||
|
|
||||||
|
const getNextWorkday = (date: Date) => {
|
||||||
|
let next = addDays(date, 1);
|
||||||
|
while (isSunday(next)) {
|
||||||
|
next = addDays(next, 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstWorkday = getNextWorkday(now);
|
||||||
|
const secondWorkday = getNextWorkday(firstWorkday);
|
||||||
|
|
||||||
|
const firstDay = formatCrimeaDate(firstWorkday);
|
||||||
|
const secondDay = formatCrimeaDate(secondWorkday);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${firstDay}, Первая половина дня`,
|
`${firstDay}, Первая половина дня`,
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function getCorsHeaders(request: Request, _access: 'public' | 'private')
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o));
|
const allowed = ALLOWED_ORIGINS.includes(origin);
|
||||||
if (!allowed) return null;
|
if (!allowed) return null;
|
||||||
return {
|
return {
|
||||||
'Access-Control-Allow-Origin': origin,
|
'Access-Control-Allow-Origin': origin,
|
||||||
|
|
@ -113,60 +113,104 @@ class RateLimitError extends Error {
|
||||||
|
|
||||||
export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
|
export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
|
||||||
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
|
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
|
||||||
const tableName = 'rate_limits';
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const { data: blocked } = await supabase
|
// Use atomic SQL function to avoid race conditions
|
||||||
.from(tableName)
|
const { data, error } = await supabase.rpc('check_rate_limit', {
|
||||||
.select('blocked_until')
|
p_scope: scope,
|
||||||
.eq('scope', scope)
|
p_key: key,
|
||||||
.eq('rate_key', key)
|
p_max_count: maxCount,
|
||||||
.gt('blocked_until', now.toISOString())
|
p_window_seconds: windowSeconds,
|
||||||
.limit(1);
|
p_block_seconds: blockSeconds,
|
||||||
|
});
|
||||||
if (blocked && blocked.length > 0) {
|
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
||||||
const { data: recent, error } = await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.select('id, count')
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gte('window_start', windowStart.toISOString());
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Rate limit check error:', error);
|
console.error('Rate limit check error:', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0;
|
if (data && !data.allowed) {
|
||||||
|
|
||||||
if (totalCount >= maxCount) {
|
|
||||||
const blockedUntil = new Date(now.getTime() + blockSeconds * 1000);
|
|
||||||
await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.update({ blocked_until: blockedUntil.toISOString() })
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gte('window_start', windowStart.toISOString());
|
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingRow = recent?.[0];
|
// ── UUID validation ──────────────────────────────────────────────────────
|
||||||
if (existingRow) {
|
|
||||||
await supabase
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
.from(tableName)
|
|
||||||
.update({ count: (existingRow as { count: number }).count + 1 })
|
export function isValidUuid(value: unknown): value is string {
|
||||||
.eq('id', (existingRow as { id: string }).id);
|
return typeof value === 'string' && UUID_REGEX.test(value.trim());
|
||||||
} else {
|
}
|
||||||
await supabase.from(tableName).insert({
|
|
||||||
scope,
|
export function requireUuid(value: unknown, fieldName = 'id'): string {
|
||||||
rate_key: key,
|
if (!isValidUuid(value)) {
|
||||||
window_start: now.toISOString(),
|
throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 });
|
||||||
count: 1,
|
}
|
||||||
blocked_until: null,
|
return (value as string).trim();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// ── PII masking ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function maskCustomerName(name: string | null | undefined): string | null {
|
||||||
|
if (!name || typeof name !== 'string') return null;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const firstChar = trimmed[0];
|
||||||
|
return `${firstChar}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskPhoneNumber(phone: string | null | undefined): string | null {
|
||||||
|
if (!phone || typeof phone !== 'string') return null;
|
||||||
|
const trimmed = phone.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.length < 4) return trimmed;
|
||||||
|
return `+7 *** ***-${trimmed.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Same-origin / CSRF check ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean {
|
||||||
|
const origin = request.headers.get('origin') || '';
|
||||||
|
if (!origin) return false;
|
||||||
|
return allowedOrigins.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal request verification (HMAC or shared secret) ────────────────
|
||||||
|
|
||||||
|
interface VerifyInternalOptions {
|
||||||
|
rawBody?: Uint8Array | string;
|
||||||
|
secretEnvNames?: string[];
|
||||||
|
tokenEnvNames?: string[];
|
||||||
|
allowedClockSkewSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyInternalRequest(
|
||||||
|
request: Request,
|
||||||
|
_rawBody: Uint8Array | string | undefined,
|
||||||
|
options: VerifyInternalOptions = {},
|
||||||
|
): Promise<{ authenticated: boolean; authenticatedBy: string }> {
|
||||||
|
const {
|
||||||
|
secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'],
|
||||||
|
tokenEnvNames = ['INTEGRATION_API_KEY'],
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Check for shared secret via header
|
||||||
|
const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || '';
|
||||||
|
for (const envName of secretEnvNames) {
|
||||||
|
const secret = Deno.env.get(envName);
|
||||||
|
if (secret && authHeader && authHeader === secret) {
|
||||||
|
return { authenticated: true, authenticatedBy: envName };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for API token via header
|
||||||
|
const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || '';
|
||||||
|
for (const envName of tokenEnvNames) {
|
||||||
|
const token = Deno.env.get(envName);
|
||||||
|
if (token && tokenHeader && tokenHeader === token) {
|
||||||
|
return { authenticated: true, authenticatedBy: envName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid credentials found
|
||||||
|
throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 });
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,13 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
|
const isWeekendDate = (value: string) => {
|
||||||
|
if (!isValidDate(value)) return false;
|
||||||
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
|
const weekday = date.getUTCDay();
|
||||||
|
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
||||||
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
invitation: {
|
invitation: {
|
||||||
delivery_date?: string | null;
|
delivery_date?: string | null;
|
||||||
|
|
@ -52,6 +59,11 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
||||||
|
if (isWeekendDate(deliveryDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
||||||
const availableSlots = invitation.available_slots || [];
|
const availableSlots = invitation.available_slots || [];
|
||||||
|
|
||||||
|
|
@ -138,6 +150,17 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const deliveryType = body.deliveryType || "delivery";
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
|
|
||||||
|
if (invitation.order_group_id) {
|
||||||
|
const { data: currentGroup, error: groupError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id, delivery_status, delivery_address, customer_address")
|
||||||
|
.eq("id", invitation.order_group_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (groupError) {
|
||||||
|
throw groupError;
|
||||||
|
}
|
||||||
|
|
||||||
// When user switches from pickup to delivery but has no address → requires_address
|
// When user switches from pickup to delivery but has no address → requires_address
|
||||||
const hasAddress = invitation.delivery_address?.trim() || currentGroup?.delivery_address?.trim() || currentGroup?.customer_address?.trim();
|
const hasAddress = invitation.delivery_address?.trim() || currentGroup?.delivery_address?.trim() || currentGroup?.customer_address?.trim();
|
||||||
const effectiveDeliveryStatus = deliveryType === "pickup"
|
const effectiveDeliveryStatus = deliveryType === "pickup"
|
||||||
|
|
@ -146,17 +169,6 @@ Deno.serve(async (request) => {
|
||||||
? "agreed"
|
? "agreed"
|
||||||
: "requires_address";
|
: "requires_address";
|
||||||
|
|
||||||
if (invitation.order_group_id) {
|
|
||||||
const { data: currentGroup, error: groupError } = await supabase
|
|
||||||
.from("order_groups")
|
|
||||||
.select("id, delivery_status")
|
|
||||||
.eq("id", invitation.order_group_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (groupError) {
|
|
||||||
throw groupError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") {
|
if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
@ -208,6 +220,8 @@ Deno.serve(async (request) => {
|
||||||
if (deliveryType === "pickup") {
|
if (deliveryType === "pickup") {
|
||||||
groupUpdateData.pickup_date = body.pickupDate || requestedSlot.deliveryDate || null;
|
groupUpdateData.pickup_date = body.pickupDate || requestedSlot.deliveryDate || null;
|
||||||
groupUpdateData.pickup_time_slot = body.pickupTimeSlot || requestedSlot.deliveryTime || null;
|
groupUpdateData.pickup_time_slot = body.pickupTimeSlot || requestedSlot.deliveryTime || null;
|
||||||
|
// Pickup orders don't need a driver — clear assignment
|
||||||
|
groupUpdateData.assigned_driver_id = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: groupUpdateError } = await supabase
|
const { error: groupUpdateError } = await supabase
|
||||||
|
|
@ -256,6 +270,7 @@ Deno.serve(async (request) => {
|
||||||
ok: true,
|
ok: true,
|
||||||
orderGroupId: invitation.order_group_id,
|
orderGroupId: invitation.order_group_id,
|
||||||
deliveryStatus: effectiveDeliveryStatus,
|
deliveryStatus: effectiveDeliveryStatus,
|
||||||
|
pickupCode: groupUpdateData.pickup_code || null,
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,11 @@ Deno.serve(async (request) => {
|
||||||
const clientIp = getClientIp(request);
|
const clientIp = getClientIp(request);
|
||||||
const userAgent = request.headers.get("user-agent") || null;
|
const userAgent = request.headers.get("user-agent") || null;
|
||||||
|
|
||||||
// Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n
|
// Insert with OTP hash only — no plaintext in DB
|
||||||
// n8n will clear otp_code after sending SMS
|
|
||||||
const { error: insertError } = await supabase.from("login_otps").insert({
|
const { error: insertError } = await supabase.from("login_otps").insert({
|
||||||
email,
|
email,
|
||||||
name: userName,
|
name: userName,
|
||||||
role: userRole,
|
role: userRole,
|
||||||
otp_code: otp,
|
|
||||||
otp_code_hash: otpCodeHash,
|
otp_code_hash: otpCodeHash,
|
||||||
ip_address: clientIp,
|
ip_address: clientIp,
|
||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ type UpdateDeliveryChoiceBody = {
|
||||||
orderGroupId?: string;
|
orderGroupId?: string;
|
||||||
deliveryDate?: string;
|
deliveryDate?: string;
|
||||||
deliveryTime?: string;
|
deliveryTime?: string;
|
||||||
|
deliveryType?: string;
|
||||||
|
pickupDate?: string;
|
||||||
|
pickupTimeSlot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
@ -111,12 +114,14 @@ Deno.serve(async (request) => {
|
||||||
const orderGroupId = String(body.orderGroupId || "").trim();
|
const orderGroupId = String(body.orderGroupId || "").trim();
|
||||||
const deliveryDate = String(body.deliveryDate || "").trim();
|
const deliveryDate = String(body.deliveryDate || "").trim();
|
||||||
const deliveryTime = normalizeDeliveryTime(String(body.deliveryTime || "").trim());
|
const deliveryTime = normalizeDeliveryTime(String(body.deliveryTime || "").trim());
|
||||||
|
const deliveryType = String(body.deliveryType || "delivery").trim();
|
||||||
|
|
||||||
if (!orderGroupId) {
|
if (!orderGroupId) {
|
||||||
return jsonResponse({ ok: false, error: "orderGroupId is required" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "orderGroupId is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAllowedDeliveryDate(deliveryDate)) {
|
// Pickup orders don't require a delivery date — allow skipping date validation
|
||||||
|
if (deliveryType !== "pickup" && !isAllowedDeliveryDate(deliveryDate)) {
|
||||||
return jsonResponse({ ok: false, error: "Выберите будущий будний день доставки" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Выберите будущий будний день доставки" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +152,7 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const { data: currentGroup, error: currentGroupError } = await supabase
|
const { data: currentGroup, error: currentGroupError } = await supabase
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("id, delivery_status, delivery_invitation_id")
|
.select("id, delivery_status, delivery_invitation_id, pickup_code")
|
||||||
.eq("id", orderGroupId)
|
.eq("id", orderGroupId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|
@ -155,15 +160,29 @@ Deno.serve(async (request) => {
|
||||||
throw currentGroupError;
|
throw currentGroupError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: group, error: groupUpdateError } = await supabase
|
const effectiveDeliveryStatus = deliveryType === "pickup" ? "pickup" : "agreed";
|
||||||
.from("order_groups")
|
|
||||||
.update({
|
const updateData: Record<string, unknown> = {
|
||||||
delivery_status: "agreed",
|
delivery_status: effectiveDeliveryStatus,
|
||||||
delivery_date: deliveryDate,
|
delivery_date: deliveryDate || null,
|
||||||
delivery_time: deliveryTime,
|
delivery_time: deliveryTime || null,
|
||||||
|
delivery_type: deliveryType,
|
||||||
notification_status: "confirmed",
|
notification_status: "confirmed",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
};
|
||||||
|
|
||||||
|
if (deliveryType === "pickup") {
|
||||||
|
updateData.pickup_date = body.pickupDate || deliveryDate || null;
|
||||||
|
updateData.pickup_time_slot = body.pickupTimeSlot || deliveryTime || null;
|
||||||
|
updateData.assigned_driver_id = null;
|
||||||
|
} else {
|
||||||
|
updateData.pickup_date = null;
|
||||||
|
updateData.pickup_time_slot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: group, error: groupUpdateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update(updateData)
|
||||||
.eq("id", orderGroupId)
|
.eq("id", orderGroupId)
|
||||||
.select("*")
|
.select("*")
|
||||||
.single();
|
.single();
|
||||||
|
|
@ -198,7 +217,8 @@ Deno.serve(async (request) => {
|
||||||
actor_user_id: actor.userId,
|
actor_user_id: actor.userId,
|
||||||
actor_role: actor.role,
|
actor_role: actor.role,
|
||||||
old_delivery_status: currentGroup.delivery_status || null,
|
old_delivery_status: currentGroup.delivery_status || null,
|
||||||
new_delivery_status: "agreed",
|
new_delivery_status: effectiveDeliveryStatus,
|
||||||
|
delivery_type: deliveryType,
|
||||||
delivery_date: deliveryDate,
|
delivery_date: deliveryDate,
|
||||||
delivery_time: deliveryTime,
|
delivery_time: deliveryTime,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -81,26 +81,22 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify OTP — compare hash (new) with fallback to plaintext (old records)
|
// 3. Verify OTP — compare SHA-256 hashes only (no plaintext fallback)
|
||||||
const submittedOtpHash = await hashText(otp);
|
const submittedOtpHash = await hashText(otp);
|
||||||
let otpMatches = false;
|
let otpMatches = false;
|
||||||
|
|
||||||
if (otpRecord.otp_code_hash) {
|
if (otpRecord.otp_code_hash) {
|
||||||
// New flow: compare SHA-256 hashes
|
|
||||||
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
|
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
|
||||||
} else if (otpRecord.otp_code) {
|
|
||||||
// Legacy fallback: plaintext comparison for old records
|
|
||||||
otpMatches = otpRecord.otp_code === otp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!otpMatches) {
|
if (!otpMatches) {
|
||||||
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Mark as verified and clear plaintext if present
|
// 4. Mark as verified
|
||||||
await supabase
|
await supabase
|
||||||
.from("login_otps")
|
.from("login_otps")
|
||||||
.update({ verified: true, otp_code: "" })
|
.update({ verified: true })
|
||||||
.eq("id", otpRecord.id);
|
.eq("id", otpRecord.id);
|
||||||
|
|
||||||
// Delete all other unverified OTPs for this email
|
// Delete all other unverified OTPs for this email
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ alter table public.order_groups add column if not exists last_sms_error text;
|
||||||
alter table public.order_groups add column if not exists next_notification_check_at timestamptz;
|
alter table public.order_groups add column if not exists next_notification_check_at timestamptz;
|
||||||
alter table public.order_groups add column if not exists delivery_date date;
|
alter table public.order_groups add column if not exists delivery_date date;
|
||||||
alter table public.order_groups add column if not exists delivery_time text;
|
alter table public.order_groups add column if not exists delivery_time text;
|
||||||
|
alter table public.order_groups add column if not exists synced_to_1c_at timestamptz;
|
||||||
|
|
||||||
alter table public.orders add column if not exists source_order_number text;
|
alter table public.orders add column if not exists source_order_number text;
|
||||||
alter table public.orders add column if not exists source_order_date date;
|
alter table public.orders add column if not exists source_order_date date;
|
||||||
|
|
@ -465,6 +466,7 @@ create index if not exists idx_delivery_invitations_expires_at on public.deliver
|
||||||
create index if not exists idx_order_groups_status on public.order_groups (status);
|
create index if not exists idx_order_groups_status on public.order_groups (status);
|
||||||
create index if not exists idx_order_groups_delivery_status on public.order_groups (delivery_status);
|
create index if not exists idx_order_groups_delivery_status on public.order_groups (delivery_status);
|
||||||
create index if not exists idx_order_groups_notification_status on public.order_groups (notification_status, next_notification_check_at);
|
create index if not exists idx_order_groups_notification_status on public.order_groups (notification_status, next_notification_check_at);
|
||||||
|
create index if not exists idx_order_groups_synced_to_1c on public.order_groups (synced_to_1c_at);
|
||||||
create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc);
|
create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc);
|
||||||
create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
|
create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
|
||||||
create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc);
|
create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc);
|
||||||
|
|
@ -707,8 +709,18 @@ begin
|
||||||
'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',
|
||||||
'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')),
|
case
|
||||||
|
when length(coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, ''))) > 0
|
||||||
|
then left(coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), 1) || '.'
|
||||||
|
else null
|
||||||
|
end,
|
||||||
|
'customerPhone',
|
||||||
|
case
|
||||||
|
when length(coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, ''))) >= 4
|
||||||
|
then '+7 *** ***-' || right(coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), 2)
|
||||||
|
else coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, ''))
|
||||||
|
end,
|
||||||
'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,
|
||||||
|
|
@ -746,6 +758,11 @@ begin
|
||||||
raise exception 'Selected slot is not available';
|
raise exception 'Selected slot is not available';
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
-- Business rule: never allow Sunday delivery (0 = Sunday in extract(dow))
|
||||||
|
if extract(dow from p_delivery_date) = 0 then
|
||||||
|
raise exception 'Delivery is not available on Sunday';
|
||||||
|
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));
|
||||||
|
|
||||||
|
|
@ -932,7 +949,7 @@ 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;
|
grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated;
|
||||||
|
|
||||||
revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public;
|
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;
|
grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to authenticated;
|
||||||
|
|
||||||
alter table public.roles enable row level security;
|
alter table public.roles enable row level security;
|
||||||
alter table public.users enable row level security;
|
alter table public.users enable row level security;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function getCorsHeaders(request: Request, _access: 'public' | 'private')
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o));
|
const allowed = ALLOWED_ORIGINS.includes(origin);
|
||||||
if (!allowed) return null;
|
if (!allowed) return null;
|
||||||
return {
|
return {
|
||||||
'Access-Control-Allow-Origin': origin,
|
'Access-Control-Allow-Origin': origin,
|
||||||
|
|
@ -113,60 +113,104 @@ class RateLimitError extends Error {
|
||||||
|
|
||||||
export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
|
export async function requireRateLimit(supabase: ReturnType<typeof createClient>, options: RateLimitOptions) {
|
||||||
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
|
const { scope, key, maxCount, windowSeconds, blockSeconds } = options;
|
||||||
const tableName = 'rate_limits';
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const { data: blocked } = await supabase
|
// Use atomic SQL function to avoid race conditions
|
||||||
.from(tableName)
|
const { data, error } = await supabase.rpc('check_rate_limit', {
|
||||||
.select('blocked_until')
|
p_scope: scope,
|
||||||
.eq('scope', scope)
|
p_key: key,
|
||||||
.eq('rate_key', key)
|
p_max_count: maxCount,
|
||||||
.gt('blocked_until', now.toISOString())
|
p_window_seconds: windowSeconds,
|
||||||
.limit(1);
|
p_block_seconds: blockSeconds,
|
||||||
|
});
|
||||||
if (blocked && blocked.length > 0) {
|
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
||||||
const { data: recent, error } = await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.select('id, count')
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gte('window_start', windowStart.toISOString());
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Rate limit check error:', error);
|
console.error('Rate limit check error:', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0;
|
if (data && !data.allowed) {
|
||||||
|
|
||||||
if (totalCount >= maxCount) {
|
|
||||||
const blockedUntil = new Date(now.getTime() + blockSeconds * 1000);
|
|
||||||
await supabase
|
|
||||||
.from(tableName)
|
|
||||||
.update({ blocked_until: blockedUntil.toISOString() })
|
|
||||||
.eq('scope', scope)
|
|
||||||
.eq('rate_key', key)
|
|
||||||
.gte('window_start', windowStart.toISOString());
|
|
||||||
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingRow = recent?.[0];
|
// ── UUID validation ──────────────────────────────────────────────────────
|
||||||
if (existingRow) {
|
|
||||||
await supabase
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
.from(tableName)
|
|
||||||
.update({ count: (existingRow as { count: number }).count + 1 })
|
export function isValidUuid(value: unknown): value is string {
|
||||||
.eq('id', (existingRow as { id: string }).id);
|
return typeof value === 'string' && UUID_REGEX.test(value.trim());
|
||||||
} else {
|
}
|
||||||
await supabase.from(tableName).insert({
|
|
||||||
scope,
|
export function requireUuid(value: unknown, fieldName = 'id'): string {
|
||||||
rate_key: key,
|
if (!isValidUuid(value)) {
|
||||||
window_start: now.toISOString(),
|
throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 });
|
||||||
count: 1,
|
}
|
||||||
blocked_until: null,
|
return (value as string).trim();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// ── PII masking ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function maskCustomerName(name: string | null | undefined): string | null {
|
||||||
|
if (!name || typeof name !== 'string') return null;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const firstChar = trimmed[0];
|
||||||
|
return `${firstChar}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskPhoneNumber(phone: string | null | undefined): string | null {
|
||||||
|
if (!phone || typeof phone !== 'string') return null;
|
||||||
|
const trimmed = phone.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.length < 4) return trimmed;
|
||||||
|
return `+7 *** ***-${trimmed.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Same-origin / CSRF check ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean {
|
||||||
|
const origin = request.headers.get('origin') || '';
|
||||||
|
if (!origin) return false;
|
||||||
|
return allowedOrigins.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal request verification (HMAC or shared secret) ────────────────
|
||||||
|
|
||||||
|
interface VerifyInternalOptions {
|
||||||
|
rawBody?: Uint8Array | string;
|
||||||
|
secretEnvNames?: string[];
|
||||||
|
tokenEnvNames?: string[];
|
||||||
|
allowedClockSkewSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyInternalRequest(
|
||||||
|
request: Request,
|
||||||
|
_rawBody: Uint8Array | string | undefined,
|
||||||
|
options: VerifyInternalOptions = {},
|
||||||
|
): Promise<{ authenticated: boolean; authenticatedBy: string }> {
|
||||||
|
const {
|
||||||
|
secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'],
|
||||||
|
tokenEnvNames = ['INTEGRATION_API_KEY'],
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Check for shared secret via header
|
||||||
|
const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || '';
|
||||||
|
for (const envName of secretEnvNames) {
|
||||||
|
const secret = Deno.env.get(envName);
|
||||||
|
if (secret && authHeader && authHeader === secret) {
|
||||||
|
return { authenticated: true, authenticatedBy: envName };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for API token via header
|
||||||
|
const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || '';
|
||||||
|
for (const envName of tokenEnvNames) {
|
||||||
|
const token = Deno.env.get(envName);
|
||||||
|
if (token && tokenHeader && tokenHeader === token) {
|
||||||
|
return { authenticated: true, authenticatedBy: envName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid credentials found
|
||||||
|
throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 });
|
||||||
|
}
|
||||||
|
|
@ -24,10 +24,20 @@ type ConfirmBody = {
|
||||||
token?: string;
|
token?: string;
|
||||||
deliveryDate?: string;
|
deliveryDate?: string;
|
||||||
deliveryTime?: string;
|
deliveryTime?: string;
|
||||||
|
deliveryType?: string;
|
||||||
|
pickupDate?: string;
|
||||||
|
pickupTimeSlot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
|
const isWeekendDate = (value: string) => {
|
||||||
|
if (!isValidDate(value)) return false;
|
||||||
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
|
const weekday = date.getUTCDay();
|
||||||
|
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
||||||
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
invitation: {
|
invitation: {
|
||||||
delivery_date?: string | null;
|
delivery_date?: string | null;
|
||||||
|
|
@ -36,6 +46,7 @@ const resolveRequestedSlot = (
|
||||||
},
|
},
|
||||||
body: ConfirmBody,
|
body: ConfirmBody,
|
||||||
) => {
|
) => {
|
||||||
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||||
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
|
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
|
||||||
|
|
||||||
|
|
@ -43,6 +54,16 @@ const resolveRequestedSlot = (
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For pickup, we allow slots outside the invitation's available_slots
|
||||||
|
if (deliveryType === "pickup") {
|
||||||
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
||||||
|
if (isWeekendDate(deliveryDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
||||||
const availableSlots = invitation.available_slots || [];
|
const availableSlots = invitation.available_slots || [];
|
||||||
|
|
||||||
|
|
@ -50,7 +71,7 @@ const resolveRequestedSlot = (
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deliveryDate, deliveryTime };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
|
|
@ -127,10 +148,12 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
|
|
||||||
if (invitation.order_group_id) {
|
if (invitation.order_group_id) {
|
||||||
const { data: currentGroup, error: groupError } = await supabase
|
const { data: currentGroup, error: groupError } = await supabase
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("id, delivery_status")
|
.select("id, delivery_status, delivery_address, customer_address")
|
||||||
.eq("id", invitation.order_group_id)
|
.eq("id", invitation.order_group_id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|
@ -138,6 +161,14 @@ Deno.serve(async (request) => {
|
||||||
throw groupError;
|
throw groupError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When user switches from pickup to delivery but has no address → requires_address
|
||||||
|
const hasAddress = invitation.delivery_address?.trim() || currentGroup?.delivery_address?.trim() || currentGroup?.customer_address?.trim();
|
||||||
|
const effectiveDeliveryStatus = deliveryType === "pickup"
|
||||||
|
? "pickup"
|
||||||
|
: hasAddress
|
||||||
|
? "agreed"
|
||||||
|
: "requires_address";
|
||||||
|
|
||||||
if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") {
|
if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
@ -177,15 +208,25 @@ Deno.serve(async (request) => {
|
||||||
throw invitationUpdateError;
|
throw invitationUpdateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: groupUpdateError } = await supabase
|
const groupUpdateData: Record<string, unknown> = {
|
||||||
.from("order_groups")
|
delivery_status: effectiveDeliveryStatus,
|
||||||
.update({
|
|
||||||
delivery_status: "agreed",
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
notification_status: "confirmed",
|
delivery_type: deliveryType,
|
||||||
|
notification_status: effectiveDeliveryStatus === "requires_address" ? "address_required" : "confirmed",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
};
|
||||||
|
|
||||||
|
if (deliveryType === "pickup") {
|
||||||
|
groupUpdateData.pickup_date = body.pickupDate || requestedSlot.deliveryDate || null;
|
||||||
|
groupUpdateData.pickup_time_slot = body.pickupTimeSlot || requestedSlot.deliveryTime || null;
|
||||||
|
// Pickup orders don't need a driver — clear assignment
|
||||||
|
groupUpdateData.assigned_driver_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: groupUpdateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update(groupUpdateData)
|
||||||
.eq("id", invitation.order_group_id);
|
.eq("id", invitation.order_group_id);
|
||||||
|
|
||||||
if (groupUpdateError) {
|
if (groupUpdateError) {
|
||||||
|
|
@ -197,10 +238,13 @@ Deno.serve(async (request) => {
|
||||||
order_group_id: invitation.order_group_id,
|
order_group_id: invitation.order_group_id,
|
||||||
action: "client_confirmed",
|
action: "client_confirmed",
|
||||||
old_value: currentGroup.delivery_status,
|
old_value: currentGroup.delivery_status,
|
||||||
new_value: "agreed",
|
new_value: effectiveDeliveryStatus,
|
||||||
details: {
|
details: {
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
delivery_type: deliveryType,
|
||||||
|
pickup_date: body.pickupDate || null,
|
||||||
|
pickup_time_slot: body.pickupTimeSlot || null,
|
||||||
source: "auto",
|
source: "auto",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -215,6 +259,9 @@ Deno.serve(async (request) => {
|
||||||
delivery_invitation_id: invitation.id,
|
delivery_invitation_id: invitation.id,
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
delivery_type: deliveryType,
|
||||||
|
pickup_date: body.pickupDate || null,
|
||||||
|
pickup_time_slot: body.pickupTimeSlot || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -222,7 +269,8 @@ Deno.serve(async (request) => {
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
orderGroupId: invitation.order_group_id,
|
orderGroupId: invitation.order_group_id,
|
||||||
deliveryStatus: "agreed",
|
deliveryStatus: effectiveDeliveryStatus,
|
||||||
|
pickupCode: groupUpdateData.pickup_code || null,
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
|
|
@ -314,6 +362,9 @@ Deno.serve(async (request) => {
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
delivery_type: deliveryType,
|
||||||
|
pickup_date: body.pickupDate || null,
|
||||||
|
pickup_time_slot: body.pickupTimeSlot || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -329,6 +380,9 @@ Deno.serve(async (request) => {
|
||||||
payload: {
|
payload: {
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
delivery_type: deliveryType,
|
||||||
|
pickup_date: body.pickupDate || null,
|
||||||
|
pickup_time_slot: body.pickupTimeSlot || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,11 @@ Deno.serve(async (request) => {
|
||||||
const clientIp = getClientIp(request);
|
const clientIp = getClientIp(request);
|
||||||
const userAgent = request.headers.get("user-agent") || null;
|
const userAgent = request.headers.get("user-agent") || null;
|
||||||
|
|
||||||
// Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n
|
// Insert with OTP hash only — no plaintext in DB
|
||||||
// n8n will clear otp_code after sending SMS
|
|
||||||
const { error: insertError } = await supabase.from("login_otps").insert({
|
const { error: insertError } = await supabase.from("login_otps").insert({
|
||||||
email,
|
email,
|
||||||
name: userName,
|
name: userName,
|
||||||
role: userRole,
|
role: userRole,
|
||||||
otp_code: otp,
|
|
||||||
otp_code_hash: otpCodeHash,
|
otp_code_hash: otpCodeHash,
|
||||||
ip_address: clientIp,
|
ip_address: clientIp,
|
||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
|
|
|
||||||
|
|
@ -81,26 +81,22 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify OTP — compare hash (new) with fallback to plaintext (old records)
|
// 3. Verify OTP — compare SHA-256 hashes only (no plaintext fallback)
|
||||||
const submittedOtpHash = await hashText(otp);
|
const submittedOtpHash = await hashText(otp);
|
||||||
let otpMatches = false;
|
let otpMatches = false;
|
||||||
|
|
||||||
if (otpRecord.otp_code_hash) {
|
if (otpRecord.otp_code_hash) {
|
||||||
// New flow: compare SHA-256 hashes
|
|
||||||
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
|
otpMatches = otpRecord.otp_code_hash === submittedOtpHash;
|
||||||
} else if (otpRecord.otp_code) {
|
|
||||||
// Legacy fallback: plaintext comparison for old records
|
|
||||||
otpMatches = otpRecord.otp_code === otp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!otpMatches) {
|
if (!otpMatches) {
|
||||||
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Mark as verified and clear plaintext if present
|
// 4. Mark as verified
|
||||||
await supabase
|
await supabase
|
||||||
.from("login_otps")
|
.from("login_otps")
|
||||||
.update({ verified: true, otp_code: "" })
|
.update({ verified: true })
|
||||||
.eq("id", otpRecord.id);
|
.eq("id", otpRecord.id);
|
||||||
|
|
||||||
// Delete all other unverified OTPs for this email
|
// Delete all other unverified OTPs for this email
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue